Stop macOS agent updates from spawning BTM entries

Unsigned darwin release tarballs replaced the Developer ID
binary on each auto-update, so Background Task Management
treated every release as a new identity and showed the generic
executable icon. Sign those archives with a stable identifier,
refuse signature downgrades, and attribute daemon/tray jobs to
Probo Agent.app via AssociatedBundleIdentifiers.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-07-30 10:43:27 +02:00
parent 0a61bea40c
commit 5e63e193cd
10 changed files with 450 additions and 42 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -24,5 +24,9 @@
<string>root</string>
<key>GroupName</key>
<string>wheel</string>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>com.probo.agent.url-handler</string>
</array>
</dict>
</plist>

View File

@@ -21,6 +21,7 @@
package service
import (
_ "embed"
"encoding/xml"
"errors"
"fmt"
@@ -39,35 +40,14 @@ const (
helperBinaryPath = "/Library/PrivilegedHelperTools/" + helperLabel
)
const launchdPlistTmpl = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{{xml .Label}}</string>
<key>ProgramArguments</key>
<array>
<string>{{xml .ExePath}}</string>
<string>run</string>
<string>--dir</string>
<string>{{xml .Dir}}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/probo-agent.log</string>
<key>StandardErrorPath</key>
<string>/var/log/probo-agent.log</string>
<key>UserName</key>
<string>root</string>
<key>GroupName</key>
<string>wheel</string>
</dict>
</plist>
`
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)
}

View File

@@ -16,5 +16,9 @@
<true/>
<key>KeepAlive</key>
<true/>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>com.probo.agent.url-handler</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -0,0 +1,106 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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")
}

View File

@@ -0,0 +1,171 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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"))
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -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)
}