diff --git a/.github/workflows/release-probo-agent.yaml b/.github/workflows/release-probo-agent.yaml
index 6bb82715c..4ea60d01f 100644
--- a/.github/workflows/release-probo-agent.yaml
+++ b/.github/workflows/release-probo-agent.yaml
@@ -126,6 +126,7 @@ jobs:
LDFLAGS="-s -w -X 'main.version=${VERSION}'"
mkdir -p dist archives staging
+ export COPYFILE_DISABLE=1
GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" \
-gcflags="-e" -o dist/probo-agent_arm64 ./cmd/probo-agent
@@ -147,6 +148,13 @@ jobs:
if [ -f cmd/probo-agent/CHANGELOG.md ]; then
cp cmd/probo-agent/CHANGELOG.md "staging/${AGENT_DIR}/"
fi
+ # Match the PKG payload signing identity so auto-update does
+ # not replace a Developer ID binary with an ad-hoc one (which
+ # multiplies macOS Background Activity entries).
+ codesign --force --options runtime --timestamp \
+ --identifier com.probo.agent \
+ --sign "${CODESIGN_IDENTITY}" "staging/${AGENT_DIR}/probo-agent"
+ codesign --verify --verbose=2 "staging/${AGENT_DIR}/probo-agent"
tar -czf "archives/${AGENT_DIR}.tar.gz" -C staging "${AGENT_DIR}"
done
diff --git a/cmd/probo-agent/installer/macos/build.sh b/cmd/probo-agent/installer/macos/build.sh
index a36c7a281..51d5906cb 100755
--- a/cmd/probo-agent/installer/macos/build.sh
+++ b/cmd/probo-agent/installer/macos/build.sh
@@ -151,12 +151,17 @@ fi
codesign_runtime() {
local path="$1"
- codesign \
- --force \
- --options runtime \
- --timestamp \
- --sign "${CODESIGN_IDENTITY}" \
- "${path}"
+ local identifier="${2:-}"
+ local -a args=(
+ --force
+ --options runtime
+ --timestamp
+ --sign "${CODESIGN_IDENTITY}"
+ )
+ if [ -n "${identifier}" ]; then
+ args+=(--identifier "${identifier}")
+ fi
+ codesign "${args[@]}" "${path}"
codesign --verify --verbose=2 "${path}"
}
@@ -391,7 +396,7 @@ RESOURCES="${STAGE}/Resources"
mkdir -p "${PAYLOAD}/usr/local/bin" "${SCRIPTS}" "${RESOURCES}"
install -m 0755 "${BINARY}" "${PAYLOAD}/usr/local/bin/probo-agent"
-codesign_runtime "${PAYLOAD}/usr/local/bin/probo-agent"
+codesign_runtime "${PAYLOAD}/usr/local/bin/probo-agent" "com.probo.agent"
mkdir -p "${PAYLOAD}/Applications"
build_probo_agent_app "${PAYLOAD}/Applications"
diff --git a/pkg/deviceagent/service/launchd.plist.tmpl b/pkg/deviceagent/service/launchd.plist.tmpl
index f2b5449d2..005c6f67a 100644
--- a/pkg/deviceagent/service/launchd.plist.tmpl
+++ b/pkg/deviceagent/service/launchd.plist.tmpl
@@ -24,5 +24,9 @@
root
GroupName
wheel
+ AssociatedBundleIdentifiers
+
+ com.probo.agent.url-handler
+
diff --git a/pkg/deviceagent/service/service_darwin.go b/pkg/deviceagent/service/service_darwin.go
index b4294e548..9d1ad5381 100644
--- a/pkg/deviceagent/service/service_darwin.go
+++ b/pkg/deviceagent/service/service_darwin.go
@@ -21,6 +21,7 @@
package service
import (
+ _ "embed"
"encoding/xml"
"errors"
"fmt"
@@ -39,35 +40,14 @@ const (
helperBinaryPath = "/Library/PrivilegedHelperTools/" + helperLabel
)
-const launchdPlistTmpl = `
-
-
-
- Label
- {{xml .Label}}
- ProgramArguments
-
- {{xml .ExePath}}
- run
- --dir
- {{xml .Dir}}
-
- RunAtLoad
-
- KeepAlive
-
- StandardOutPath
- /var/log/probo-agent.log
- StandardErrorPath
- /var/log/probo-agent.log
- UserName
- root
- GroupName
- wheel
-
-
-`
+var (
+ //go:embed launchd.plist.tmpl
+ launchdPlistTmpl string
+
+ launchdPlist = template.Must(
+ template.New("plist").Funcs(template.FuncMap{"xml": xmlEscape}).Parse(launchdPlistTmpl),
+ )
+)
func xmlEscape(v string) (string, error) {
var sb strings.Builder
@@ -126,11 +106,6 @@ func Install(cfg Config) error {
cfg.Label = DefaultLabel
}
- tmpl, err := template.New("plist").Funcs(template.FuncMap{"xml": xmlEscape}).Parse(launchdPlistTmpl)
- if err != nil {
- return fmt.Errorf("cannot parse plist template: %w", err)
- }
-
if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil {
return fmt.Errorf("cannot ensure launch daemons directory: %w", err)
}
@@ -142,7 +117,7 @@ func Install(cfg Config) error {
defer func() { _ = f.Close() }()
- if err := tmpl.Execute(f, cfg); err != nil {
+ if err := launchdPlist.Execute(f, cfg); err != nil {
return fmt.Errorf("cannot render plist: %w", err)
}
diff --git a/pkg/deviceagent/tray/launchagent.plist.tmpl b/pkg/deviceagent/tray/launchagent.plist.tmpl
index 17f290f35..0aa703b8d 100644
--- a/pkg/deviceagent/tray/launchagent.plist.tmpl
+++ b/pkg/deviceagent/tray/launchagent.plist.tmpl
@@ -16,5 +16,9 @@
KeepAlive
+ AssociatedBundleIdentifiers
+
+ com.probo.agent.url-handler
+
diff --git a/pkg/deviceagent/update/install_signature_darwin.go b/pkg/deviceagent/update/install_signature_darwin.go
new file mode 100644
index 000000000..da45ad9f7
--- /dev/null
+++ b/pkg/deviceagent/update/install_signature_darwin.go
@@ -0,0 +1,93 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+//go:build darwin
+
+package update
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+ "strings"
+
+ "go.gearno.de/kit/log"
+)
+
+// ensureSignatureCompatible refuses an update that would replace a
+// Developer ID-signed agent with a binary that lacks the same Apple
+// Team ID and code-signing Identifier. A mismatch on either field
+// creates a fresh Background Task Management identity.
+//
+// Machines already running an unsigned (ad-hoc) binary are allowed to
+// upgrade so they can recover onto a signed release.
+func ensureSignatureCompatible(
+ ctx context.Context,
+ logger *log.Logger,
+ currentPath string,
+ candidatePath string,
+) error {
+ current, err := readCodeSigningIdentity(currentPath)
+ if err != nil {
+ return fmt.Errorf("cannot read current binary code signature: %w", err)
+ }
+
+ candidate, err := readCodeSigningIdentity(candidatePath)
+ if err != nil {
+ return fmt.Errorf("cannot read candidate binary code signature: %w", err)
+ }
+
+ if current.Team == "" {
+ logger.InfoCtx(
+ ctx,
+ "current agent binary has no Apple Team ID; allowing update",
+ log.String("candidate_team_id", candidate.Team),
+ log.String("candidate_identifier", candidate.Identifier),
+ )
+
+ return nil
+ }
+
+ return codeSigningIdentitiesCompatible(current, candidate)
+}
+
+func readCodeSigningIdentity(path string) (codeSigningIdentity, error) {
+ cmd := exec.Command("/usr/bin/codesign", "-d", "--verbose=4", path)
+ out, err := cmd.CombinedOutput()
+ output := string(out)
+
+ identity := parseCodeSigningIdentity(output)
+ if identity.Team != "" {
+ return identity, nil
+ }
+
+ // Unsigned and ad-hoc binaries exit non-zero and report no Team ID.
+ // Treat those as empty rather than failing the update path.
+ if err != nil && !isUnsignedCodesignOutput(output) {
+ return codeSigningIdentity{}, fmt.Errorf(
+ "codesign -d %s: %w: %s",
+ path,
+ err,
+ strings.TrimSpace(output),
+ )
+ }
+
+ return identity, nil
+}
diff --git a/pkg/deviceagent/update/install_signature_identity.go b/pkg/deviceagent/update/install_signature_identity.go
new file mode 100644
index 000000000..8e5db1f5a
--- /dev/null
+++ b/pkg/deviceagent/update/install_signature_identity.go
@@ -0,0 +1,106 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package update
+
+import (
+ "fmt"
+ "strings"
+)
+
+// codeSigningIdentity is the Apple code-signing identity fields that
+// Background Task Management uses to key a background item.
+type codeSigningIdentity struct {
+ Team string
+ Identifier string
+}
+
+// codeSigningIdentitiesCompatible refuses an update that would change
+// the BTM identity of a Developer ID-signed agent. Both Team ID and
+// signing Identifier must match. Machines already running an unsigned
+// (ad-hoc) binary are allowed to upgrade onto a signed release.
+func codeSigningIdentitiesCompatible(
+ current codeSigningIdentity,
+ candidate codeSigningIdentity,
+) error {
+ if current.Team == "" {
+ return nil
+ }
+
+ if candidate.Team == "" {
+ return fmt.Errorf(
+ "candidate binary has no Apple Team ID (current Team ID %s); refusing signature downgrade",
+ current.Team,
+ )
+ }
+
+ if candidate.Team != current.Team {
+ return fmt.Errorf(
+ "candidate Team ID %s does not match current Team ID %s",
+ candidate.Team,
+ current.Team,
+ )
+ }
+
+ if candidate.Identifier != current.Identifier {
+ return fmt.Errorf(
+ "candidate Identifier %q does not match current Identifier %q",
+ candidate.Identifier,
+ current.Identifier,
+ )
+ }
+
+ return nil
+}
+
+func parseCodeSigningIdentity(codesignOutput string) codeSigningIdentity {
+ return codeSigningIdentity{
+ Team: parseCodesignField(codesignOutput, "TeamIdentifier="),
+ Identifier: parseCodesignField(codesignOutput, "Identifier="),
+ }
+}
+
+func parseCodesignField(codesignOutput string, prefix string) string {
+ for line := range strings.SplitSeq(codesignOutput, "\n") {
+ line = strings.TrimSpace(line)
+
+ after, ok := strings.CutPrefix(line, prefix)
+ if !ok {
+ continue
+ }
+
+ after = strings.TrimSpace(after)
+ if after == "" || after == "not set" {
+ return ""
+ }
+
+ return after
+ }
+
+ return ""
+}
+
+func isUnsignedCodesignOutput(output string) bool {
+ lower := strings.ToLower(output)
+
+ return strings.Contains(lower, "code object is not signed") ||
+ strings.Contains(lower, "not signed at all") ||
+ strings.Contains(lower, "teamidentifier=not set")
+}
diff --git a/pkg/deviceagent/update/install_signature_identity_test.go b/pkg/deviceagent/update/install_signature_identity_test.go
new file mode 100644
index 000000000..4fd00ae51
--- /dev/null
+++ b/pkg/deviceagent/update/install_signature_identity_test.go
@@ -0,0 +1,171 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package update
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseCodeSigningIdentity(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ out string
+ want codeSigningIdentity
+ }{
+ {
+ name: "developer id signed binary",
+ out: "" +
+ "Executable=/usr/local/bin/probo-agent\n" +
+ "Identifier=com.probo.agent\n" +
+ "Format=Mach-O thin (arm64)\n" +
+ "TeamIdentifier=ABCD123456\n",
+ want: codeSigningIdentity{
+ Team: "ABCD123456",
+ Identifier: "com.probo.agent",
+ },
+ },
+ {
+ name: "team identifier not set",
+ out: "" +
+ "Identifier=probo-agent\n" +
+ "Signature=adhoc\n" +
+ "TeamIdentifier=not set\n",
+ want: codeSigningIdentity{
+ Team: "",
+ Identifier: "probo-agent",
+ },
+ },
+ {
+ name: "unsigned output has empty fields",
+ out: "code object is not signed at all\n",
+ want: codeSigningIdentity{},
+ },
+ {
+ name: "empty identifier value",
+ out: "" +
+ "Identifier=\n" +
+ "TeamIdentifier=ABCD123456\n",
+ want: codeSigningIdentity{
+ Team: "ABCD123456",
+ Identifier: "",
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(
+ tc.name,
+ func(t *testing.T) {
+ t.Parallel()
+
+ got := parseCodeSigningIdentity(tc.out)
+ assert.Equal(t, tc.want, got)
+ },
+ )
+ }
+}
+
+func TestCodeSigningIdentitiesCompatible(t *testing.T) {
+ t.Parallel()
+
+ stable := codeSigningIdentity{
+ Team: "ABCD123456",
+ Identifier: "com.probo.agent",
+ }
+
+ cases := []struct {
+ name string
+ current codeSigningIdentity
+ candidate codeSigningIdentity
+ wantErr string
+ }{
+ {
+ name: "allows unsigned to signed upgrade",
+ current: codeSigningIdentity{},
+ candidate: codeSigningIdentity{
+ Team: "ABCD123456",
+ Identifier: "com.probo.agent",
+ },
+ },
+ {
+ name: "allows matching team and identifier",
+ current: stable,
+ candidate: stable,
+ },
+ {
+ name: "refuses signed to unsigned downgrade",
+ current: stable,
+ candidate: codeSigningIdentity{
+ Identifier: "com.probo.agent",
+ },
+ wantErr: "refusing signature downgrade",
+ },
+ {
+ name: "refuses team mismatch",
+ current: stable,
+ candidate: codeSigningIdentity{
+ Team: "OTHERTEAM1",
+ Identifier: "com.probo.agent",
+ },
+ wantErr: "does not match current Team ID",
+ },
+ {
+ name: "refuses same team with different identifier",
+ current: stable,
+ candidate: codeSigningIdentity{
+ Team: "ABCD123456",
+ Identifier: "com.probo.agent.other",
+ },
+ wantErr: "does not match current Identifier",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(
+ tc.name,
+ func(t *testing.T) {
+ t.Parallel()
+
+ err := codeSigningIdentitiesCompatible(tc.current, tc.candidate)
+ if tc.wantErr == "" {
+ assert.NoError(t, err)
+ return
+ }
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.wantErr)
+ },
+ )
+ }
+}
+
+func TestIsUnsignedCodesignOutput(t *testing.T) {
+ t.Parallel()
+
+ assert.True(t, isUnsignedCodesignOutput("code object is not signed at all"))
+ assert.True(t, isUnsignedCodesignOutput("TeamIdentifier=not set\n"))
+ assert.False(t, isUnsignedCodesignOutput("TeamIdentifier=ABCD123456\n"))
+}
diff --git a/pkg/deviceagent/update/install_signature_other.go b/pkg/deviceagent/update/install_signature_other.go
new file mode 100644
index 000000000..faebbd968
--- /dev/null
+++ b/pkg/deviceagent/update/install_signature_other.go
@@ -0,0 +1,38 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+//go:build !darwin
+
+package update
+
+import (
+ "context"
+
+ "go.gearno.de/kit/log"
+)
+
+func ensureSignatureCompatible(
+ _ context.Context,
+ _ *log.Logger,
+ _ string,
+ _ string,
+) error {
+ return nil
+}
diff --git a/pkg/deviceagent/update/update.go b/pkg/deviceagent/update/update.go
index e0cb2ba2d..6c73de14e 100644
--- a/pkg/deviceagent/update/update.go
+++ b/pkg/deviceagent/update/update.go
@@ -325,6 +325,10 @@ func (u *Updater) Apply(ctx context.Context, rel *Release) error {
return fmt.Errorf("cannot extract archive: %w", err)
}
+ if err := ensureSignatureCompatible(ctx, u.Logger, u.ExePath, extractedBinary); err != nil {
+ return fmt.Errorf("cannot verify code signature compatibility: %w", err)
+ }
+
if err := replaceBinary(u.ExePath, extractedBinary); err != nil {
return fmt.Errorf("cannot replace agent binary: %w", err)
}