Install macOS helper from PKG for XPC enroll

Browser enrollment used osascript on every elevate. Ship a signed
privileged helper installed at PKG time so probo:// can enroll over
XPC with no second admin prompt. Add make install/uninstall/clean for
local PKG test loops, and show alerts only on failure.

Mirror the Go lint path for the macOS SPM package: Make
targets, root configs, and a Linux CI job. Keep checks
syntax-only so they do not need a macOS SDK. Format the
existing sources so the new gates start clean.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-07-20 18:18:04 +02:00
parent 754d12d583
commit 85864a580c
42 changed files with 1903 additions and 341 deletions

View File

@@ -380,6 +380,41 @@ jobs:
reviewdog -f=eslint -reporter=github-pr-review -filter-mode=nofilter -name="eslint ($dir)" || true reviewdog -f=eslint -reporter=github-pr-review -filter-mode=nofilter -name="eslint ($dir)" || true
done done
lint-swift:
name: "lint-swift"
runs-on: "runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/extras=s3-cache"
permissions:
contents: "read"
pull-requests: "write"
steps:
- uses: "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" # v6
with:
submodules: recursive
- uses: "runs-on/action@d141ef83eb66d096ce8afc767e09115a65c63b60" # v2
- uses: "swift-actions/setup-swift@7ca6abe6b3b0e8b5421b88be48feee39cbf52c6a" # v2.4.0
with:
swift-version: "6.0"
- uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # v1.5.0
- name: "Install SwiftLint"
run: |
curl -sL "https://github.com/realm/SwiftLint/releases/download/0.65.0/swiftlint_linux_amd64.zip" -o /tmp/swiftlint.zip
sudo unzip -o /tmp/swiftlint.zip -d /usr/local/bin
sudo chmod +x /usr/local/bin/swiftlint
swiftlint version
- name: "Run swift format"
run: |
sources="$(find cmd/probo-agent/installer/macos/enroll-ui \( -name '*.swift' ! -name '*.generated.swift' ! -path '*/.build/*' \) | sort)"
swift format lint --configuration .swift-format --strict --parallel $sources
- name: "Run SwiftLint"
run: swiftlint lint --strict --config .swiftlint.yml --cache-path /tmp/swiftlint-cache
- name: "Annotate PR with SwiftLint findings"
if: failure() && github.event_name == 'pull_request'
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
swiftlint lint --config .swiftlint.yml --cache-path /tmp/swiftlint-cache 2>&1 | \
reviewdog -f=swiftlint -reporter=github-pr-review -filter-mode=nofilter -name="swiftlint" || true
test: test:
name: "test" name: "test"
runs-on: "runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/extras=s3-cache" runs-on: "runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/extras=s3-cache"

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
bin/ bin/
node_modules/ node_modules/
.cache/
.turbo .turbo
.vscode .vscode
.cursor/* .cursor/*

28
.swift-format Normal file
View File

@@ -0,0 +1,28 @@
{
"version": 1,
"lineLength": 100,
"indentation": {
"spaces": 4
},
"tabWidth": 4,
"maximumBlankLines": 1,
"respectsExistingLineBreaks": true,
"lineBreakBeforeControlFlowKeywords": false,
"lineBreakBeforeEachArgument": false,
"lineBreakBeforeEachGenericRequirement": false,
"lineBreakBetweenDeclarationAttributes": false,
"prioritizeKeepingFunctionOutputTogether": false,
"indentConditionalCompilationBlocks": true,
"indentSwitchCaseLabels": false,
"spacesAroundRangeFormationOperators": false,
"spacesBeforeEndOfLineComments": 2,
"indentBlankLines": false,
"multiElementCollectionTrailingCommas": true,
"reflowMultilineStringLiterals": "never",
"fileScopedDeclarationPrivacy": {
"accessLevel": "private"
},
"orderedImports": {
"includeConditionalImports": false
}
}

30
.swiftlint.yml Normal file
View File

@@ -0,0 +1,30 @@
# Small opt-in set (mirrors .golangci.yml default: none + few linters).
# Syntax-only rules so lint runs on Linux without SourceKit/macOS SDKs.
only_rules:
- duplicate_imports
- empty_count
- empty_parameters
- empty_string
- force_cast
- force_try
- force_unwrapping
- orphaned_doc_comment
- redundant_nil_coalescing
- statement_position
- trailing_newline
- trailing_whitespace
- unused_closure_parameter
- unused_optional_binding
- vertical_whitespace
included:
- cmd/probo-agent/installer/macos/enroll-ui
excluded:
- cmd/probo-agent/installer/macos/enroll-ui/.build
- "**/*.generated.swift"
- "**/*.tmpl"
force_cast: error
force_try: error
force_unwrapping: error

View File

@@ -17,6 +17,13 @@ SYFT ?= syft
TAIL ?= tail TAIL ?= tail
ECHO ?= echo ECHO ?= echo
GOLINTCMD ?= golangci-lint GOLINTCMD ?= golangci-lint
SWIFTLINTCMD ?= swiftlint
SWIFTCMD ?= swift
SWIFT_ENROLL_UI ?= cmd/probo-agent/installer/macos/enroll-ui
SWIFT_FORMAT_CONFIG ?= .swift-format
SWIFTLINT_CONFIG ?= .swiftlint.yml
swift_sources = $(shell find $(SWIFT_ENROLL_UI) \( -name '*.swift' ! -name '*.generated.swift' ! -path '*/.build/*' \) | sort)
DOCKER_BUILD_FLAGS?= DOCKER_BUILD_FLAGS?=
DOCKER_BUILD= DOCKER_BUILDKIT=1 $(DOCKER) build $(DOCKER_BUILD_FLAGS) DOCKER_BUILD= DOCKER_BUILDKIT=1 $(DOCKER) build $(DOCKER_BUILD_FLAGS)
@@ -90,15 +97,12 @@ PROBOCTL_SRC= cmd/proboctl/main.go
PROBO_AGENT_BIN= bin/probo-agent PROBO_AGENT_BIN= bin/probo-agent
PROBO_AGENT_SRC= ./cmd/probo-agent PROBO_AGENT_SRC= ./cmd/probo-agent
# Menu bar / tray enrollment is macOS and Windows only; those hosts need CGO. # Menu bar / tray enrollment is macOS and Windows; only macOS needs CGO.
PROBO_AGENT_TARGET_OS= $(if $(GOOS),$(GOOS),$(shell $(GO) env GOOS)) PROBO_AGENT_TARGET_OS= $(if $(GOOS),$(GOOS),$(shell $(GO) env GOOS))
PROBO_AGENT_CGO= 0 PROBO_AGENT_CGO= 0
ifeq ($(PROBO_AGENT_TARGET_OS),darwin) ifeq ($(PROBO_AGENT_TARGET_OS),darwin)
PROBO_AGENT_CGO= 1 PROBO_AGENT_CGO= 1
endif endif
ifeq ($(PROBO_AGENT_TARGET_OS),windows)
PROBO_AGENT_CGO= 1
endif
ifdef WITH_APPS ifdef WITH_APPS
GENERATED += relay GENERATED += relay
@@ -120,6 +124,19 @@ lint-go: vet go-fmt go-fix go-lint
lint-js: lint-js:
$(NPM) run lint $(NPM) run lint
.PHONY: lint-swift
lint-swift: swift-fmt swift-lint ## Lint Swift enroll-ui (format + SwiftLint)
.PHONY: swift-fmt
swift-fmt: ## Check Swift formatting with swift format
@command -v $(SWIFTCMD) >/dev/null 2>&1 || { echo "error: '$(SWIFTCMD)' not found; install the Swift toolchain (Xcode on macOS)"; exit 1; }
$(SWIFTCMD) format lint --configuration $(SWIFT_FORMAT_CONFIG) --strict --parallel $(swift_sources)
.PHONY: swift-lint
swift-lint: ## Lint Swift with SwiftLint
@command -v $(SWIFTLINTCMD) >/dev/null 2>&1 || { echo "error: '$(SWIFTLINTCMD)' not found; install SwiftLint (e.g. brew install swiftlint)"; exit 1; }
$(SWIFTLINTCMD) lint --strict --config $(SWIFTLINT_CONFIG) --cache-path .cache/swiftlint
.PHONY: vet .PHONY: vet
vet: generate embed vet: generate embed
$(GO_VET) ./... $(GO_VET) ./...
@@ -393,6 +410,14 @@ fmt: fmt-go ## Format Go code
fmt-go: ## Format Go code fmt-go: ## Format Go code
go fmt ./... go fmt ./...
.PHONY: fmt-swift
fmt-swift: ## Format Swift enroll-ui sources
@command -v $(SWIFTCMD) >/dev/null 2>&1 || { echo "error: '$(SWIFTCMD)' not found; install the Swift toolchain (Xcode on macOS)"; exit 1; }
$(SWIFTCMD) format --configuration $(SWIFT_FORMAT_CONFIG) --in-place --parallel $(swift_sources)
@if command -v $(SWIFTLINTCMD) >/dev/null 2>&1; then \
$(SWIFTLINTCMD) lint --fix --config $(SWIFTLINT_CONFIG) --cache-path .cache/swiftlint; \
fi
.PHONY: clean .PHONY: clean
clean: ## Clean the project (node_modules and build artifacts) clean: ## Clean the project (node_modules and build artifacts)
$(RM) -rf bin/* $(RM) -rf bin/*

View File

@@ -5,6 +5,26 @@ documented in this file.
## Unreleased ## Unreleased
### Added
- macOS privileged helper (`com.probo.agent.helper`) embedded in
`Probo Agent.app` and installed by PKG postinstall for XPC-driven
browser enrollment (no SMJobBless / admin prompt on enroll).
- Hidden `probo-agent enroll-url --preflight` JSON output for the URL handler.
- `make -C cmd/probo-agent install|uninstall|clean` for local macOS PKG
test loops (install tears down leftovers first).
### Changed
- Browser enrollment via `Probo Agent.app` uses HelperClient + XPC only
(osascript elevation and enroll-time SMJobBless removed).
- macOS PKG / app builds require `CODESIGN_IDENTITY` and `APPLE_TEAM_ID`.
- CLI `enroll-url` on macOS refuses elevation; use the signed app deeplink
or `sudo probo-agent install`.
- macOS `probo-agent uninstall` requires root (`sudo`).
- PKG preinstall removes stale privileged helper files on upgrade;
postinstall reinstalls the helper as root.
## [0.1.1] - 2026-06-11 ## [0.1.1] - 2026-06-11
### Changed ### Changed

View File

@@ -1,15 +1,37 @@
# Local build / install helpers for probo-agent.
#
# macOS (primary for browser-enroll testing):
# make install build signed PKG, uninstall leftovers, install PKG
# make uninstall remove all system artifacts (idempotent)
# make clean uninstall + wipe local build caches
#
# Requires on Darwin: CODESIGN_IDENTITY, APPLE_TEAM_ID
# Optional: INSTALLER_IDENTITY; notarize via APPLE_ID+APPLE_ID_PASSWORD
# (stored into a keychain profile; submit uses --keychain-profile)
#
# CLI-only (no app / helper), any Unix:
# make install-cli
# make run
CP ?= cp CP ?= cp
MKDIR ?= mkdir -p MKDIR ?= mkdir -p
RMRF ?= rm -rf
SUDO ?= sudo SUDO ?= sudo
REPO_ROOT= $(abspath ../..)
PROBO_AGENT_BIN= $(REPO_ROOT)/bin/probo-agent
VERSION= $(shell cat VERSION)
STATE_DIR= $(HOME)/.local/share/probo-agent-dev STATE_DIR= $(HOME)/.local/share/probo-agent-dev
CACHE_ROOT= $(HOME)/.cache/probo-agent-dev CACHE_ROOT= $(HOME)/.cache/probo-agent-dev
DEV_TAG= probo-agent/dev DEV_TAG= probo-agent/dev
BINARY= /usr/local/bin/probo-agent BINARY= /usr/local/bin/probo-agent
RELEASE_DIR= $(CACHE_ROOT)/release/$(DEV_TAG) RELEASE_DIR= $(CACHE_ROOT)/release/$(DEV_TAG)
INSTALL_SCRIPT= installer/install.sh INSTALL_SCRIPT= installer/install.sh
REPO_ROOT= ../.. MACOS_BUILD_SCRIPT= installer/macos/build.sh
PROBO_AGENT_BIN= $(REPO_ROOT)/bin/probo-agent MACOS_UNINSTALL_SCRIPT= installer/macos/uninstall.sh
MACOS_REINSTALL_SCRIPT= installer/macos/reinstall.sh
ENROLL_UI_BUILD= installer/macos/enroll-ui/.build
UNAME_S:= $(shell uname -s) UNAME_S:= $(shell uname -s)
UNAME_M:= $(shell uname -m) UNAME_M:= $(shell uname -m)
@@ -26,12 +48,19 @@ endif
ifeq ($(UNAME_M),x86_64) ifeq ($(UNAME_M),x86_64)
ARCH_LABEL= x86_64 ARCH_LABEL= x86_64
BUILD_ARCH= amd64
else ifeq ($(UNAME_M),amd64)
ARCH_LABEL= x86_64
BUILD_ARCH= amd64
else ifeq ($(UNAME_M),arm64) else ifeq ($(UNAME_M),arm64)
ARCH_LABEL= arm64 ARCH_LABEL= arm64
BUILD_ARCH= arm64
else ifeq ($(UNAME_M),aarch64) else ifeq ($(UNAME_M),aarch64)
ARCH_LABEL= arm64 ARCH_LABEL= arm64
BUILD_ARCH= arm64
else else
ARCH_LABEL= ARCH_LABEL=
BUILD_ARCH=
endif endif
AGENT_DIR= probo-agent_$(OS_LABEL)_$(ARCH_LABEL) AGENT_DIR= probo-agent_$(OS_LABEL)_$(ARCH_LABEL)
@@ -40,26 +69,104 @@ ARCHIVE_PATH= $(RELEASE_DIR)/$(ARCHIVE_NAME)
STAGING_DIR= $(CACHE_ROOT)/staging/$(AGENT_DIR) STAGING_DIR= $(CACHE_ROOT)/staging/$(AGENT_DIR)
BUILD_BINARY= $(STAGING_DIR)/probo-agent BUILD_BINARY= $(STAGING_DIR)/probo-agent
PKG= $(REPO_ROOT)/dist/probo-agent_$(VERSION)_darwin_$(ARCH_LABEL).pkg
INSTALL_ARGS?= --skip-service --dir "$(STATE_DIR)" INSTALL_ARGS?= --skip-service --dir "$(STATE_DIR)"
INSTALL_ENV= PROBO_AGENT_RELEASE_TAG="$(DEV_TAG)" \ INSTALL_ENV= PROBO_AGENT_RELEASE_TAG="$(DEV_TAG)" \
PROBO_AGENT_RELEASE_BASE="file://$(abspath $(RELEASE_DIR))" \ PROBO_AGENT_RELEASE_BASE="file://$(abspath $(RELEASE_DIR))" \
PROBO_AGENT_SKIP_CHECKSUM_VERIFY=true \ PROBO_AGENT_SKIP_CHECKSUM_VERIFY=true \
PROBO_AGENT_STATE_DIR="$(STATE_DIR)" \ PROBO_AGENT_STATE_DIR="$(STATE_DIR)" \
PROBO_SERVER_URL="$(PROBO_SERVER_URL)" \ PROBO_SERVER_URL="$(PROBO_SERVER_URL)" \
PROBO_ENROLLMENT_TOKEN="$(PROBO_ENROLLMENT_TOKEN)" PROBO_ENROLLMENT_TOKEN="$(PROBO_ENROLLMENT_TOKEN)"
.PHONY: all install run clean .PHONY: help all pkg install uninstall clean clean-build install-cli run
all: install .PHONY: $(PROBO_AGENT_BIN)
install: $(ARCHIVE_PATH) all: help
$(SUDO) $(INSTALL_ENV) sh "$(INSTALL_SCRIPT)" $(INSTALL_ARGS)
run: help: ## Show targets
@printf '%s\n' \
'Targets:' \
' install macOS: build signed PKG, wipe previous install, install PKG' \
' other: same as install-cli' \
' uninstall Remove system install (macOS: full PKG/helper/app teardown)' \
' clean uninstall + remove local build caches' \
' clean-build Remove local caches only (no sudo)' \
' pkg Build signed macOS PKG to dist/ (Darwin only)' \
' install-cli Install binary via installer/install.sh (dev state dir)' \
' run Foreground run against install-cli state dir' \
'' \
'Darwin install requires CODESIGN_IDENTITY and APPLE_TEAM_ID.'
# --- primary local test loop -------------------------------------------------
install: ## Install for local testing (PKG on macOS)
ifeq ($(UNAME_S),Darwin)
$(MAKE) pkg
$(SUDO) "$(MACOS_REINSTALL_SCRIPT)" "$(PKG)"
else
$(MAKE) install-cli
endif
uninstall: ## Remove all system artifacts
ifeq ($(UNAME_S),Darwin)
$(SUDO) "$(MACOS_UNINSTALL_SCRIPT)"
else
@if [ -x "$(BINARY)" ]; then \
$(SUDO) "$(BINARY)" uninstall || true; \
fi
$(SUDO) $(RMRF) "$(BINARY)"
endif
clean: uninstall clean-build ## uninstall + wipe build caches
clean-build: ## Wipe local build caches (no sudo)
$(RMRF) "$(STATE_DIR)" "$(CACHE_ROOT)" "$(ENROLL_UI_BUILD)"
@# Unquoted globs so the shell can expand dist artifacts.
-$(RMRF) $(REPO_ROOT)/dist/probo-agent_*.pkg
@# Native binary under /usr/local is owned by root after install; leave it
@# to `uninstall`. Never remove it here without sudo.
# --- macOS PKG ---------------------------------------------------------------
pkg: $(PKG) ## Build signed .pkg into dist/
$(PKG): $(PROBO_AGENT_BIN)
ifeq ($(UNAME_S),Darwin)
@if [ -z "$(CODESIGN_IDENTITY)" ]; then \
echo 'error: CODESIGN_IDENTITY is required' >&2; exit 2; \
fi
@if [ -z "$(APPLE_TEAM_ID)" ]; then \
echo 'error: APPLE_TEAM_ID is required' >&2; exit 2; \
fi
@if [ -z "$(BUILD_ARCH)" ]; then \
echo 'error: unsupported arch $(UNAME_M)' >&2; exit 2; \
fi
@$(MKDIR) "$(dir $(PKG))"
@CODESIGN_IDENTITY="$(CODESIGN_IDENTITY)" \
APPLE_TEAM_ID="$(APPLE_TEAM_ID)" \
INSTALLER_IDENTITY="$(INSTALLER_IDENTITY)" \
APPLE_ID="$(APPLE_ID)" \
APPLE_ID_PASSWORD="$(APPLE_ID_PASSWORD)" \
NOTARYTOOL_KEYCHAIN_PROFILE="$(NOTARYTOOL_KEYCHAIN_PROFILE)" \
sh "$(MACOS_BUILD_SCRIPT)" \
--binary "$(PROBO_AGENT_BIN)" \
--arch "$(BUILD_ARCH)" \
--version "$(VERSION)" \
--output "$(PKG)"
else
@echo 'error: pkg target is macOS-only' >&2
@exit 1
endif
# --- CLI-only (no Probo Agent.app / helper) ----------------------------------
install-cli: $(ARCHIVE_PATH) ## Install binary only via install.sh
@$(SUDO) $(INSTALL_ENV) sh "$(INSTALL_SCRIPT)" $(INSTALL_ARGS)
run: ## Run agent in foreground (install-cli state dir)
$(SUDO) "$(BINARY)" run --dir "$(STATE_DIR)" $(SUDO) "$(BINARY)" run --dir "$(STATE_DIR)"
clean:
rm -rf "$(STATE_DIR)" "$(CACHE_ROOT)" "$(BINARY)"
$(ARCHIVE_PATH): $(BUILD_BINARY) $(ARCHIVE_PATH): $(BUILD_BINARY)
$(MKDIR) "$(RELEASE_DIR)" $(MKDIR) "$(RELEASE_DIR)"
tar -czf "$(ARCHIVE_PATH)" -C "$(CACHE_ROOT)/staging" "$(AGENT_DIR)" tar -czf "$(ARCHIVE_PATH)" -C "$(CACHE_ROOT)/staging" "$(AGENT_DIR)"

View File

@@ -0,0 +1,80 @@
// 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 main
import (
"bytes"
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestEnrollURLPreflight(t *testing.T) {
t.Parallel()
root := newRootCmd()
root.SetArgs([]string{
"enroll-url",
"--preflight",
"--dir", t.TempDir(),
"probo://enroll?server=https%3A%2F%2Fexample.com&token=abc123",
})
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stdout)
err := root.Execute()
require.NoError(t, err)
var payload enrollPreflightResponse
err = json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &payload)
require.NoError(t, err)
require.Equal(t, "https://example.com", payload.Server)
require.Equal(t, "abc123", payload.Token)
require.False(t, payload.AlreadyEnrolled)
require.Contains(t, payload.ConfigDir, "TestEnrollURLPreflight")
}
func TestWriteEnrollPreflight(t *testing.T) {
t.Parallel()
var stdout bytes.Buffer
err := writeEnrollPreflight(
&stdout,
"https://example.com",
"token-value",
"/var/lib/probo-agent",
true,
)
require.NoError(t, err)
var payload enrollPreflightResponse
err = json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &payload)
require.NoError(t, err)
require.True(t, payload.AlreadyEnrolled)
require.Equal(t, "token-value", payload.Token)
}

View File

@@ -9,6 +9,7 @@
@@PKG_ARCH@@ reserved (legacy; host filter uses @@HOST_ARCHS@@) @@PKG_ARCH@@ reserved (legacy; host filter uses @@HOST_ARCHS@@)
@@HOST_ARCHS@@ host arch filter used by Installer.app @@HOST_ARCHS@@ host arch filter used by Installer.app
(e.g. arm64 or arm64,x86_64 for universal) (e.g. arm64 or arm64,x86_64 for universal)
@@IDENTIFIER@@ package identifier (default com.probo.agent)
--> -->
<installer-gui-script minSpecVersion="2"> <installer-gui-script minSpecVersion="2">
<title>Probo Device Posture Agent @@VERSION@@</title> <title>Probo Device Posture Agent @@VERSION@@</title>
@@ -38,20 +39,20 @@
<license file="license.txt" mime-type="text/plain"/> <license file="license.txt" mime-type="text/plain"/>
<conclusion file="conclusion.html" mime-type="text/html"/> <conclusion file="conclusion.html" mime-type="text/html"/>
<pkg-ref id="com.getprobo.agent" <pkg-ref id="@@IDENTIFIER@@"
version="@@VERSION@@" version="@@VERSION@@"
onConclusion="none">probo-agent-component.pkg</pkg-ref> onConclusion="none">probo-agent-component.pkg</pkg-ref>
<choices-outline> <choices-outline>
<line choice="default"> <line choice="default">
<line choice="com.getprobo.agent"/> <line choice="@@IDENTIFIER@@"/>
</line> </line>
</choices-outline> </choices-outline>
<choice id="default"/> <choice id="default"/>
<choice id="com.getprobo.agent" <choice id="@@IDENTIFIER@@"
title="Probo Device Posture Agent" title="Probo Device Posture Agent"
description="Installs probo-agent, the menu bar helper LaunchAgent, and Probo Agent.app for probo:// enrollment. The LaunchDaemon starts after enrollment."> description="Installs probo-agent, the menu bar helper LaunchAgent, and Probo Agent.app for probo:// enrollment. The LaunchDaemon starts after enrollment.">
<pkg-ref id="com.getprobo.agent"/> <pkg-ref id="@@IDENTIFIER@@"/>
</choice> </choice>
</installer-gui-script> </installer-gui-script>

View File

@@ -48,6 +48,9 @@
<li>Installs <code>Probo Agent.app</code> to <li>Installs <code>Probo Agent.app</code> to
<code>/Applications</code> so browser enrollment deep links <code>/Applications</code> so browser enrollment deep links
(<code>probo://</code>) work.</li> (<code>probo://</code>) work.</li>
<li>Installs the privileged helper
<code>com.probo.agent.helper</code> so browser enrollment can
finish without a second admin password prompt.</li>
<li>Registers the menu bar helper LaunchAgent <li>Registers the menu bar helper LaunchAgent
<code>com.probo.agent.tray</code> in <code>com.probo.agent.tray</code> in
<code>/Library/LaunchAgents</code>.</li> <code>/Library/LaunchAgents</code>.</li>

View File

@@ -11,28 +11,26 @@
# --output PATH Output .pkg path. Defaults to # --output PATH Output .pkg path. Defaults to
# dist/probo-agent_${VER}_darwin_${ARCH}.pkg. # dist/probo-agent_${VER}_darwin_${ARCH}.pkg.
# #
# Optional environment variables (auditor-mode compatible): # Required environment variables:
# CODESIGN_IDENTITY Developer ID Application identity. When # CODESIGN_IDENTITY Developer ID Application identity. Signs the
# set, signs the agent binary and Probo # agent binary, Probo Agent.app, and embedded helper.
# Agent.app with hardened runtime before # APPLE_TEAM_ID Apple Developer Team ID (helper client requirement).
# packaging. #
# Optional (auditor-mode compatible):
# INSTALLER_IDENTITY Developer ID Installer identity. When # INSTALLER_IDENTITY Developer ID Installer identity. When
# set, passes --sign to productbuild. # set, passes --sign to productbuild.
# APPLE_ID Apple ID for notarytool store-credentials. # APPLE_ID Apple ID for notarytool store-credentials.
# APPLE_ID_PASSWORD App-specific password; used only to # APPLE_ID_PASSWORD App-specific password; used only to
# populate a keychain profile (not passed # populate a keychain profile (not passed
# to long-lived notarytool submit). # to long-lived notarytool submit).
# APPLE_TEAM_ID Apple Developer Team ID. # NOTARYTOOL_KEYCHAIN_PROFILE Keychain profile name for store/submit.
# NOTARYTOOL_KEYCHAIN_PROFILE Existing notarytool keychain profile. # Defaults to probo-agent-notary.
# Defaults to probo-agent-notary when
# storing from APPLE_ID / APPLE_ID_PASSWORD.
# #
# Notarization is enabled when NOTARYTOOL_KEYCHAIN_PROFILE is set, or # Notarization is enabled when APPLE_ID and APPLE_ID_PASSWORD are both
# when APPLE_ID and APPLE_ID_PASSWORD are both set (APPLE_TEAM_ID also # set. INSTALLER_IDENTITY is then required. The script stores credentials
# required). CODESIGN_IDENTITY and INSTALLER_IDENTITY are then required. # into the keychain profile, then notarizes and staples the .app before
# The script stores credentials into the keychain profile when a # packaging and the signed .pkg via --keychain-profile so the password
# password is provided, then notarizes and staples the .app before # is not on submit argv for the long --wait.
# packaging and the signed .pkg via --keychain-profile.
# #
# Must run on macOS: pkgbuild, productbuild, and swift build are # Must run on macOS: pkgbuild, productbuild, and swift build are
# Apple-only tools. The build also compiles Probo Agent.app (the # Apple-only tools. The build also compiles Probo Agent.app (the
@@ -47,13 +45,13 @@ BINARY=""
ARCH="" ARCH=""
VERSION="" VERSION=""
OUTPUT="" OUTPUT=""
IDENTIFIER="com.getprobo.agent" IDENTIFIER="com.probo.agent"
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-}" CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-}"
INSTALLER_IDENTITY="${INSTALLER_IDENTITY:-}" INSTALLER_IDENTITY="${INSTALLER_IDENTITY:-}"
APPLE_ID="${APPLE_ID:-}" APPLE_ID="${APPLE_ID:-}"
APPLE_ID_PASSWORD="${APPLE_ID_PASSWORD:-}" APPLE_ID_PASSWORD="${APPLE_ID_PASSWORD:-}"
APPLE_TEAM_ID="${APPLE_TEAM_ID:-}" APPLE_TEAM_ID="${APPLE_TEAM_ID:-}"
NOTARYTOOL_KEYCHAIN_PROFILE="${NOTARYTOOL_KEYCHAIN_PROFILE:-}" NOTARYTOOL_KEYCHAIN_PROFILE="${NOTARYTOOL_KEYCHAIN_PROFILE:-probo-agent-notary}"
usage() { usage() {
sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0" sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0"
@@ -101,28 +99,42 @@ case "${ARCH}" in
;; ;;
esac esac
# --arch universal advertises both hostArchitectures in Distribution.xml. # --arch sets hostArchitectures in Distribution.xml. Refuse a binary that
# Refuse a single-slice binary so Installer cannot install on a CPU the # lacks the advertised slice(s) so Installer cannot install on a CPU the
# agent cannot run on. # agent cannot run on.
if [ "${ARCH}" = "universal" ]; then if ! command -v lipo >/dev/null 2>&1; then
if ! command -v lipo >/dev/null 2>&1; then echo "error: lipo is required to validate --binary architecture (run on macOS)" >&2
echo "error: lipo is required to validate a universal --binary (run on macOS)" >&2 exit 1
exit 1
fi
BINARY_ARCHS="$(lipo -archs "${BINARY}")"
has_arm64=false
has_x86_64=false
for arch_slice in ${BINARY_ARCHS}; do
case "${arch_slice}" in
arm64) has_arm64=true ;;
x86_64) has_x86_64=true ;;
esac
done
if [ "${has_arm64}" != true ] || [ "${has_x86_64}" != true ]; then
echo "error: --arch universal requires a fat binary with arm64 and x86_64 slices (got: ${BINARY_ARCHS}); use lipo -create" >&2
exit 2
fi
fi fi
BINARY_ARCHS="$(lipo -archs "${BINARY}")"
has_arm64=false
has_x86_64=false
for arch_slice in ${BINARY_ARCHS}; do
case "${arch_slice}" in
arm64) has_arm64=true ;;
x86_64) has_x86_64=true ;;
esac
done
case "${ARCH}" in
amd64)
if [ "${has_x86_64}" != true ]; then
echo "error: --arch amd64 requires a binary with an x86_64 slice (got: ${BINARY_ARCHS})" >&2
exit 2
fi
;;
arm64)
if [ "${has_arm64}" != true ]; then
echo "error: --arch arm64 requires a binary with an arm64 slice (got: ${BINARY_ARCHS})" >&2
exit 2
fi
;;
universal)
if [ "${has_arm64}" != true ] || [ "${has_x86_64}" != true ]; then
echo "error: --arch universal requires a fat binary with arm64 and x86_64 slices (got: ${BINARY_ARCHS}); use lipo -create" >&2
exit 2
fi
;;
esac
if [ -z "${VERSION}" ]; then if [ -z "${VERSION}" ]; then
VERSION="$(cat "${REPO_ROOT}/cmd/probo-agent/VERSION")" VERSION="$(cat "${REPO_ROOT}/cmd/probo-agent/VERSION")"
@@ -140,30 +152,26 @@ if ! command -v swift >/dev/null 2>&1; then
echo "error: swift is required to build Probo Agent.app (run on macOS)" >&2 echo "error: swift is required to build Probo Agent.app (run on macOS)" >&2
exit 1 exit 1
fi fi
if [ -z "${CODESIGN_IDENTITY}" ]; then
echo "error: CODESIGN_IDENTITY is required (privileged helper must be signed)" >&2
exit 2
fi
if [ -z "${APPLE_TEAM_ID}" ]; then
echo "error: APPLE_TEAM_ID is required (SMAuthorizedClients team requirement)" >&2
exit 2
fi
notarize_enabled=false notarize_enabled=false
if [ -n "${NOTARYTOOL_KEYCHAIN_PROFILE}" ]; then if [ -n "${APPLE_ID}" ] && [ -n "${APPLE_ID_PASSWORD}" ]; then
notarize_enabled=true
elif [ -n "${APPLE_ID}" ] && [ -n "${APPLE_ID_PASSWORD}" ] && [ -n "${APPLE_TEAM_ID}" ]; then
NOTARYTOOL_KEYCHAIN_PROFILE="probo-agent-notary"
notarize_enabled=true notarize_enabled=true
fi fi
if [ "${notarize_enabled}" = true ]; then if [ "${notarize_enabled}" = true ] && [ -z "${INSTALLER_IDENTITY}" ]; then
if [ -z "${CODESIGN_IDENTITY}" ]; then echo "error: notarization requires INSTALLER_IDENTITY" >&2
echo "error: notarization requires CODESIGN_IDENTITY" >&2 exit 2
exit 2
fi
if [ -z "${INSTALLER_IDENTITY}" ]; then
echo "error: notarization requires INSTALLER_IDENTITY" >&2
exit 2
fi
fi fi
sign_macho() { sign_macho() {
local path="$1" local path="$1"
if [ -z "${CODESIGN_IDENTITY}" ]; then
return 0
fi
codesign \ codesign \
--force \ --force \
--options runtime \ --options runtime \
@@ -175,8 +183,15 @@ sign_macho() {
sign_app_bundle() { sign_app_bundle() {
local app_path="$1" local app_path="$1"
if [ -z "${CODESIGN_IDENTITY}" ]; then local helper_path="${app_path}/Contents/Library/LaunchServices/com.probo.agent.helper"
return 0 if [ -x "${helper_path}" ]; then
codesign \
--force \
--options runtime \
--timestamp \
--sign "${CODESIGN_IDENTITY}" \
"${helper_path}"
codesign --verify --verbose=2 "${helper_path}"
fi fi
codesign \ codesign \
--force \ --force \
@@ -194,11 +209,8 @@ sign_app_bundle() {
} }
ensure_notarytool_credentials() { ensure_notarytool_credentials() {
if [ -z "${APPLE_ID_PASSWORD}" ]; then if [ -z "${APPLE_ID}" ] || [ -z "${APPLE_ID_PASSWORD}" ]; then
return 0 echo "error: APPLE_ID and APPLE_ID_PASSWORD are required to store notarytool credentials" >&2
fi
if [ -z "${APPLE_ID}" ]; then
echo "error: APPLE_ID_PASSWORD requires APPLE_ID to store notarytool credentials" >&2
exit 2 exit 2
fi fi
# Password appears on argv only for this short-lived store. Submits # Password appears on argv only for this short-lived store. Submits
@@ -327,6 +339,7 @@ sed \
-e "s|@@VERSION@@|${VERSION}|g" \ -e "s|@@VERSION@@|${VERSION}|g" \
-e "s|@@PKG_ARCH@@|${PKG_ARCH}|g" \ -e "s|@@PKG_ARCH@@|${PKG_ARCH}|g" \
-e "s|@@HOST_ARCHS@@|${HOST_ARCHS}|g" \ -e "s|@@HOST_ARCHS@@|${HOST_ARCHS}|g" \
-e "s|@@IDENTIFIER@@|${IDENTIFIER}|g" \
"${SCRIPT_DIR}/Distribution.xml.tmpl" > "${DISTRIBUTION}" "${SCRIPT_DIR}/Distribution.xml.tmpl" > "${DISTRIBUTION}"
mkdir -p "$(dirname "${OUTPUT}")" mkdir -p "$(dirname "${OUTPUT}")"

View File

@@ -1,2 +1,6 @@
# SwiftPM
.build/ .build/
.swiftpm/ .swiftpm/
# Rendered by build-app.sh from *.tmpl (do not commit)
Shared/*.generated.swift

View File

@@ -0,0 +1,43 @@
import Foundation
import ProboAgentShared
public struct EnrollPreflightResult: Decodable {
public let server: String
public let token: String
public let alreadyEnrolled: Bool
public let configDir: String
}
public enum EnrollmentFlow {
private static let agentExecutablePath = ProboAgentHelperConstants.agentExecutablePath
public static func runPreflight(rawURL: String) throws -> EnrollPreflightResult {
let process = Process()
process.executableURL = URL(fileURLWithPath: agentExecutablePath)
process.arguments = ["enroll-url", "--preflight", rawURL]
let output = Pipe()
process.standardOutput = output
process.standardError = output
try process.run()
let data = try output.fileHandleForReading.readToEnd() ?? Data()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let message = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
throw HelperClientError.operationFailed(process.terminationStatus, message)
}
return try JSONDecoder().decode(EnrollPreflightResult.self, from: data)
}
public static func installViaHelper(preflight: EnrollPreflightResult) throws {
try HelperClient.shared.install(
serverURL: preflight.server,
enrollmentToken: preflight.token,
configDir: preflight.configDir
)
}
}

View File

@@ -0,0 +1,214 @@
import Foundation
import ProboAgentShared
public enum HelperClientError: LocalizedError {
case helperNotInstalled
case connectionFailed(String)
case operationFailed(Int32, String?)
public var errorDescription: String? {
switch self {
case .helperNotInstalled:
return """
Privileged helper is not installed. Reinstall the Probo Agent \
package, then try enrollment again.
"""
case .connectionFailed(let message):
return "Cannot connect to privileged helper: \(message)"
case .operationFailed(let code, let message):
if let message, !message.isEmpty {
return message
}
return "Privileged operation failed (exit \(code))."
}
}
}
/// Once-only sync bridge for an in-flight XPC call. Reply and proxy error
/// handlers both finish here so disconnects unblock waiters immediately.
private final class XPCCallCompletion: @unchecked Sendable {
private let lock = NSLock()
private let semaphore = DispatchSemaphore(value: 0)
private var finished = false
private var error: Error?
func succeed() {
complete(nil)
}
func fail(_ error: Error) {
complete(error)
}
private func complete(_ error: Error?) {
lock.lock()
defer { lock.unlock() }
guard !finished else { return }
finished = true
self.error = error
semaphore.signal()
}
/// Waits for succeed/fail. Throws the stored error, or `timedOut` on timeout.
func wait(timeout: TimeInterval, timedOut: @autoclosure () -> Error) throws {
if semaphore.wait(timeout: .now() + timeout) == .timedOut {
throw timedOut()
}
if let error {
throw error
}
}
/// Waits for succeed/fail. Returns `false` on timeout; throws the stored error.
func wait(timeout: TimeInterval) throws -> Bool {
if semaphore.wait(timeout: .now() + timeout) == .timedOut {
return false
}
if let error {
throw error
}
return true
}
}
final public class HelperClient {
public static let shared = HelperClient()
/// Serializes public install within this process so privileged helper
/// work never overlaps. Does not coordinate across processes.
private let operationLock = NSLock()
private init() {}
public func install(
serverURL: String,
enrollmentToken: String,
configDir: String = ProboAgentHelperConstants.defaultConfigDir
) throws {
operationLock.lock()
defer { operationLock.unlock() }
try ensureHelperReady()
try withRemoteProxy { proxy, completion in
proxy.install(
serverURL: serverURL,
enrollmentToken: enrollmentToken,
configDir: configDir
) { exitCode, output in
if exitCode != 0 {
completion.fail(HelperClientError.operationFailed(exitCode, output))
} else {
completion.succeed()
}
}
// Headroom over probo-agent install's 60s deadline plus local
// service/tray setup so we report the command's real outcome.
try completion.wait(
timeout: 120,
timedOut: HelperClientError.connectionFailed(
"install timed out waiting for privileged helper"
))
}
}
/// The helper is installed by the PKG postinstall (as root). Enrollment
/// never calls SMJobBless no admin prompt on the browser path.
private func ensureHelperReady() throws {
guard isHelperInstalled() else {
throw HelperClientError.helperNotInstalled
}
let installedVersion = try installedHelperVersion()
if installedVersion == nil {
throw HelperClientError.connectionFailed(
"helper is installed but not responding; reinstall the Probo Agent package"
)
}
if installedVersion != ProboAgentHelperConstants.helperVersion {
NSLog(
"probo-agent helper client: version mismatch (installed=%@ expected=%@)",
installedVersion ?? "nil",
ProboAgentHelperConstants.helperVersion
)
}
try verifyHelperResponds()
}
private func isHelperInstalled() -> Bool {
FileManager.default.fileExists(
atPath: "/Library/PrivilegedHelperTools/\(ProboAgentHelperConstants.helperLabel)"
)
}
private func installedHelperVersion() throws -> String? {
try withRemoteProxy { proxy, completion in
var version: String?
proxy.getVersion { value in
version = value
completion.succeed()
}
guard try completion.wait(timeout: 10) else {
return nil
}
return version
}
}
private func verifyHelperResponds() throws {
try withRemoteProxy { proxy, completion in
var ok = false
proxy.ping { value in
ok = value
completion.succeed()
}
try completion.wait(
timeout: 10,
timedOut: HelperClientError.connectionFailed(
"helper did not respond to ping (timeout)")
)
if !ok {
throw HelperClientError.connectionFailed("helper did not respond to ping")
}
}
}
/// Creates a dedicated XPC connection for the duration of `body`, then
/// invalidates it. Callers must finish waiting for replies inside `body`
/// so the connection outlives the reply.
private func withRemoteProxy<T>(
_ body: (ProboAgentHelperProtocol, XPCCallCompletion) throws -> T
) throws -> T {
let connection = NSXPCConnection(
machServiceName: ProboAgentHelperConstants.machServiceName,
options: .privileged
)
connection.remoteObjectInterface = NSXPCInterface(with: ProboAgentHelperProtocol.self)
connection.resume()
defer { connection.invalidate() }
let completion = XPCCallCompletion()
guard
let proxy = connection.remoteObjectProxyWithErrorHandler({ error in
NSLog(
"probo-agent helper XPC error: %@",
error.localizedDescription
)
completion.fail(
HelperClientError.connectionFailed(error.localizedDescription)
)
}) as? ProboAgentHelperProtocol
else {
throw HelperClientError.connectionFailed("cannot create remote proxy")
}
return try body(proxy, completion)
}
}

View File

@@ -0,0 +1,103 @@
import Foundation
import ProboAgentShared
import os
final class Helper: NSObject, ProboAgentHelperProtocol, NSXPCListenerDelegate {
private static let log = Logger(
subsystem: "com.probo.agent.helper",
category: "Helper"
)
func getVersion(withReply reply: @escaping (String) -> Void) {
reply(ProboAgentHelperConstants.helperVersion)
}
func ping(withReply reply: @escaping (Bool) -> Void) {
Self.log.info("ping")
reply(true)
}
func install(
serverURL: String,
enrollmentToken: String,
configDir: String,
withReply reply: @escaping (Int32, String?) -> Void
) {
let trimmedServer = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedToken = enrollmentToken.trimmingCharacters(in: .whitespacesAndNewlines)
let dir = configDir.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedServer.isEmpty, !trimmedToken.isEmpty else {
reply(1, "server URL and enrollment token are required")
return
}
// Pass the token via env rather than argv so it does not show up in
// process listings (ps / Activity Monitor). Install already accepts
// PROBO_ENROLLMENT_TOKEN when --enrollment-token is omitted.
var args = [
"install",
"--server", trimmedServer,
]
if !dir.isEmpty {
args.append(contentsOf: ["--dir", dir])
}
var environment = ProcessInfo.processInfo.environment
environment["PROBO_ENROLLMENT_TOKEN"] = trimmedToken
let result = runAgent(args: args, environment: environment)
reply(result.exitCode, result.output)
}
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection)
-> Bool
{
guard ClientAuth.accepts(connection: connection) else {
Self.log.error(
"refused XPC connection pid=\(connection.processIdentifier, privacy: .public)")
return false
}
Self.log.info(
"accepted XPC connection pid=\(connection.processIdentifier, privacy: .public)")
connection.exportedInterface = NSXPCInterface(with: ProboAgentHelperProtocol.self)
connection.exportedObject = self
connection.resume()
return true
}
private struct CommandResult {
let exitCode: Int32
let output: String?
}
private func runAgent(args: [String], environment: [String: String]? = nil) -> CommandResult {
let process = Process()
process.executableURL = URL(fileURLWithPath: ProboAgentHelperConstants.agentExecutablePath)
process.arguments = args
if let environment {
process.environment = environment
}
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
let data = try pipe.fileHandleForReading.readToEnd() ?? Data()
process.waitUntilExit()
let text = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return CommandResult(
exitCode: process.terminationStatus,
output: text?.isEmpty == false ? text : nil
)
} catch {
return CommandResult(exitCode: 1, output: error.localizedDescription)
}
}
}

View File

@@ -0,0 +1,21 @@
<?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>CFBundleIdentifier</key>
<string>com.probo.agent.helper</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Probo Agent Helper</string>
<key>CFBundleShortVersionString</key>
<string>@@VERSION@@</string>
<key>CFBundleVersion</key>
<string>@@VERSION@@</string>
<key>SMAuthorizedClients</key>
<array>
<string>@@CLIENT_DESIGNATED_REQUIREMENT@@</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,18 @@
<?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>com.probo.agent.helper</string>
<key>MachServices</key>
<dict>
<key>com.probo.agent.helper</key>
<true/>
</dict>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>com.probo.agent.url-handler</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
import Foundation
import ProboAgentShared
import os
private let log = Logger(subsystem: "com.probo.agent.helper", category: "main")
let helper = Helper()
let listener = NSXPCListener(machServiceName: ProboAgentHelperConstants.machServiceName)
listener.delegate = helper
listener.resume()
log.info("listening on \(ProboAgentHelperConstants.machServiceName, privacy: .public)")
RunLoop.main.run()

View File

@@ -6,7 +6,8 @@
Placeholders are substituted by build-app.sh: Placeholders are substituted by build-app.sh:
@@VERSION@@ agent version, e.g. 0.1.0 @@VERSION@@ agent version, e.g. 0.1.0
@@HELPER_DESIGNATED_REQUIREMENT@@ codesign requirement for embedded helper
--> -->
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
@@ -15,7 +16,7 @@
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>probo-agent-url-handler</string> <string>probo-agent-url-handler</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>com.getprobo.agent.url-handler</string> <string>com.probo.agent.url-handler</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Probo Agent</string> <string>Probo Agent</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -41,5 +42,10 @@
</array> </array>
</dict> </dict>
</array> </array>
<key>SMPrivilegedExecutables</key>
<dict>
<key>com.probo.agent.helper</key>
<string>@@HELPER_DESIGNATED_REQUIREMENT@@</string>
</dict>
</dict> </dict>
</plist> </plist>

View File

@@ -4,11 +4,43 @@ import PackageDescription
let package = Package( let package = Package(
name: "probo-agent-url-handler", name: "probo-agent-url-handler",
platforms: [ platforms: [
.macOS(.v11), .macOS(.v11)
],
products: [
.library(name: "ProboAgentShared", targets: ["ProboAgentShared"]),
.library(name: "HelperClient", targets: ["HelperClient"]),
.executable(name: "com.probo.agent.helper", targets: ["com.probo.agent.helper"]),
.executable(name: "probo-agent-url-handler", targets: ["probo-agent-url-handler"]),
], ],
targets: [ targets: [
.target(
name: "ProboAgentShared",
path: "Shared",
exclude: [
"HelperVersion.generated.swift.tmpl",
"SigningConstants.generated.swift.tmpl",
],
linkerSettings: [
.linkedFramework("Security")
]
),
.target(
name: "HelperClient",
dependencies: ["ProboAgentShared"],
path: "HelperClient"
),
.executableTarget(
name: "com.probo.agent.helper",
dependencies: ["ProboAgentShared"],
path: "HelperTool",
exclude: ["Info.plist.tmpl", "Launchd.plist.tmpl"],
linkerSettings: [
.linkedFramework("Security")
]
),
.executableTarget( .executableTarget(
name: "probo-agent-url-handler", name: "probo-agent-url-handler",
dependencies: ["HelperClient", "ProboAgentShared"],
path: "URLHandlerSources" path: "URLHandlerSources"
), ),
] ]

View File

@@ -0,0 +1,183 @@
import Foundation
import Security
import os
public enum ClientAuth {
private static let log = Logger(
subsystem: "com.probo.agent.helper",
category: "ClientAuth"
)
/// Validates an incoming XPC connection from the Probo Agent URL handler app.
public static func accepts(connection: NSXPCConnection) -> Bool {
guard let code = copyGuestCode(for: connection) else {
return false
}
// Developer ID + hardened runtime sets CS_RUNTIME on the code directory
// (kSecCodeInfoFlags). kSecCodeInfoStatus is only present for some
// dynamic queries and was missing here, which rejected every client.
if !hasAcceptableCodeStatus(code) {
return false
}
guard let requirement = clientRequirement() else {
log.error("reject XPC client (missing client requirement)")
return false
}
var secRequirement: SecRequirement?
guard
SecRequirementCreateWithString(
requirement as CFString,
SecCSFlags(),
&secRequirement
) == errSecSuccess, let secRequirement
else {
log.error("reject XPC client (invalid requirement string)")
return false
}
let check = SecCodeCheckValidity(code, SecCSFlags(), secRequirement)
if check != errSecSuccess {
log.error("reject XPC client (requirement not satisfied, status=\(check))")
return false
}
log.info("accepted XPC client pid=\(connection.processIdentifier, privacy: .public)")
return true
}
private static func copyGuestCode(for connection: NSXPCConnection) -> SecCode? {
if let token = auditToken(from: connection) {
var mutableToken = token
let tokenData = withUnsafeBytes(of: &mutableToken) { Data($0) }
if let code = copyGuestCode(attributes: [
kSecGuestAttributeAudit as String: tokenData
]) {
return code
}
log.info("audit-token guest lookup failed; trying pid")
} else {
log.info("no audit token on XPC connection; trying pid")
}
let pid = connection.processIdentifier
guard pid > 0 else {
log.error("reject XPC client (invalid pid)")
return nil
}
return copyGuestCode(attributes: [
kSecGuestAttributePid as String: NSNumber(value: pid)
])
}
private static func copyGuestCode(attributes: [String: Any]) -> SecCode? {
var code: SecCode?
let status = SecCodeCopyGuestWithAttributes(
nil,
attributes as CFDictionary,
SecCSFlags(),
&code
)
guard status == errSecSuccess else {
log.error("reject XPC client (SecCodeCopyGuestWithAttributes=\(status))")
return nil
}
return code
}
private static func hasAcceptableCodeStatus(_ code: SecCode) -> Bool {
var staticCode: SecStaticCode?
guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess,
let staticCode
else {
log.error("reject XPC client (cannot copy static code)")
return false
}
var csInfo: CFDictionary?
guard
SecCodeCopySigningInformation(
staticCode,
SecCSFlags(rawValue: kSecCSDynamicInformation),
&csInfo
) == errSecSuccess,
let info = csInfo as? [String: Any]
else {
log.error("reject XPC client (cannot read signing information)")
return false
}
// Prefer dynamic status when present; fall back to code-directory flags
// (where hardened-runtime CS_RUNTIME lives for Developer ID binaries).
let statusValue = uint32Value(info[kSecCodeInfoStatus as String])
let flagsValue = uint32Value(info[kSecCodeInfoFlags as String])
let bits = statusValue ?? flagsValue
guard let bits else {
log.error("reject XPC client (no status/flags in signing info)")
return false
}
// Accept hardened-runtime clients (Developer ID + --options runtime sets
// CS_RUNTIME). Also accept the older CS_HARD|CS_KILL pair.
let csHard: UInt32 = 0x100
let csKill: UInt32 = 0x200
let csRuntime: UInt32 = 0x10000
let hasHardKill = (bits & (csHard | csKill)) == (csHard | csKill)
let hasRuntime = (bits & csRuntime) == csRuntime
if !hasHardKill && !hasRuntime {
log.error(
"reject XPC client (bits=0x\(String(bits, radix: 16)), need runtime or hard|kill)"
)
return false
}
return true
}
private static func uint32Value(_ value: Any?) -> UInt32? {
switch value {
case let number as NSNumber:
return number.uint32Value
case let value as UInt32:
return value
case let value as Int:
return UInt32(truncatingIfNeeded: value)
default:
return nil
}
}
/// Reads NSXPCConnection.auditToken across SDK/runtime differences.
/// Modern macOS exposes it as an ObjC property; KVC may return Data or NSValue.
private static func auditToken(from connection: NSXPCConnection) -> audit_token_t? {
if let data = connection.value(forKey: "auditToken") as? Data,
data.count == MemoryLayout<audit_token_t>.size
{
return data.withUnsafeBytes { raw in
raw.load(as: audit_token_t.self)
}
}
if let value = connection.value(forKey: "auditToken") as? NSValue {
var token = audit_token_t()
value.getValue(&token)
return token
}
return nil
}
private static func clientRequirement() -> String? {
guard let teamID = ProboAgentSigningConstants.teamID, !teamID.isEmpty else {
return nil
}
return """
anchor apple generic and identifier "\(ProboAgentHelperConstants.clientBundleID)" \
and certificate leaf[subject.OU] = "\(teamID)"
"""
}
}

View File

@@ -0,0 +1,13 @@
import Foundation
@objc(ProboAgentHelperProtocol)
public protocol ProboAgentHelperProtocol: NSObjectProtocol {
func getVersion(withReply reply: @escaping (String) -> Void)
func ping(withReply reply: @escaping (Bool) -> Void)
func install(
serverURL: String,
enrollmentToken: String,
configDir: String,
withReply reply: @escaping (Int32, String?) -> Void
)
}

View File

@@ -0,0 +1,4 @@
// Generated by build-app.sh — do not edit.
enum ProboAgentHelperVersion {
static let value = "@@VERSION@@"
}

View File

@@ -0,0 +1,12 @@
import Foundation
public enum ProboAgentHelperConstants {
public static let machServiceName = "com.probo.agent.helper"
public static let helperLabel = "com.probo.agent.helper"
public static let clientBundleID = "com.probo.agent.url-handler"
public static let agentExecutablePath = "/usr/local/bin/probo-agent"
public static let defaultConfigDir = "/var/lib/probo-agent"
public static let enrolledMarkerPath = "/var/run/probo-agent/enrolled"
public static let helperVersion = ProboAgentHelperVersion.value
}

View File

@@ -0,0 +1,4 @@
// Generated by build-app.sh — do not edit.
enum ProboAgentSigningConstants {
static let teamID: String? = @@TEAM_ID_OPTION@@
}

View File

@@ -1,61 +1,10 @@
import AppKit import AppKit
import Darwin
import Foundation import Foundation
import HelperClient
// Fixed install location written by the macOS PKG postinstall script
// (cmd/probo-agent/installer/macos/scripts/postinstall, BINARY).
private let agentExecutablePath = "/usr/local/bin/probo-agent"
private enum EnrollmentCallbackState: String, Codable {
case success
case failure
}
private struct EnrollmentCallbackPayload: Codable {
let state: EnrollmentCallbackState
let message: String?
}
private enum EnrollmentCallbackStore {
static var statusFileURL: URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("probo-agent-enrollment-status.json")
}
static var lockFileURL: URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("probo-agent-enrollment-ui.lock")
}
static func isWizardRunning() -> Bool {
guard
let data = try? Data(contentsOf: lockFileURL),
let pidText = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
let pid = Int32(pidText),
pid > 0
else {
return false
}
return kill(pid, 0) == 0
}
static func writeStatus(
state: EnrollmentCallbackState,
message: String?
) {
let payload = EnrollmentCallbackPayload(state: state, message: message)
guard let data = try? JSONEncoder().encode(payload) else {
return
}
try? data.write(to: statusFileURL, options: [.atomic])
}
}
private final class URLHandlerApp: NSObject, NSApplicationDelegate { private final class URLHandlerApp: NSObject, NSApplicationDelegate {
private var didReceiveURL = false private var didReceiveURL = false
private var idleTimer: Timer?
override init() { override init() {
super.init() super.init()
@@ -69,127 +18,89 @@ private final class URLHandlerApp: NSObject, NSApplicationDelegate {
} }
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { _ in NSLog("probo-agent url-handler: launched")
if !self.didReceiveURL { idleTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: false) { [weak self] _ in
NSApp.terminate(nil) guard let self, !self.didReceiveURL else { return }
} NSLog("probo-agent url-handler: no URL received; exiting")
NSApp.terminate(nil)
} }
} }
func application(_ application: NSApplication, open urls: [URL]) {
guard let rawURL = urls.first?.absoluteString else { return }
beginEnrollmentIfNeeded(rawURL: rawURL)
}
@objc private func handleGetURLEvent( @objc private func handleGetURLEvent(
_ event: NSAppleEventDescriptor, _ event: NSAppleEventDescriptor,
withReplyEvent replyEvent: NSAppleEventDescriptor withReplyEvent replyEvent: NSAppleEventDescriptor
) { ) {
guard let rawURL = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { guard let rawURL = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else {
reportFailure("Enrollment link is missing.") presentFailure("Enrollment link is missing.")
return return
} }
guard !didReceiveURL else { return } beginEnrollmentIfNeeded(rawURL: rawURL)
}
private func beginEnrollmentIfNeeded(rawURL: String) {
guard !didReceiveURL else { return }
didReceiveURL = true didReceiveURL = true
idleTimer?.invalidate()
idleTimer = nil
runEnrollment(for: rawURL) runEnrollment(for: rawURL)
} }
private func runEnrollment(for rawURL: String) { private func runEnrollment(for rawURL: String) {
let shouldNotifyWizard = EnrollmentCallbackStore.isWizardRunning() NSLog("probo-agent url-handler: starting enrollment")
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
let process = Process() do {
process.executableURL = URL(fileURLWithPath: agentExecutablePath) NSLog("probo-agent url-handler: preflight…")
process.arguments = ["enroll-url", rawURL] let preflight = try EnrollmentFlow.runPreflight(rawURL: rawURL)
if preflight.alreadyEnrolled {
let output = Pipe() NSLog("probo-agent url-handler: already enrolled")
process.standardOutput = output DispatchQueue.main.async {
process.standardError = output NSApp.terminate(nil)
}
var outputData = Data()
let readHandle = output.fileHandleForReading
let readDone = DispatchSemaphore(value: 0)
readHandle.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty {
handle.readabilityHandler = nil
readDone.signal()
return return
} }
outputData.append(chunk)
}
defer { readHandle.readabilityHandler = nil } NSLog("probo-agent url-handler: install via helper…")
try EnrollmentFlow.installViaHelper(preflight: preflight)
NSLog("probo-agent url-handler: install completed")
do { DispatchQueue.main.async {
try process.run() NSApp.terminate(nil)
}
} catch { } catch {
readDone.signal() NSLog(
"probo-agent url-handler: enrollment failed: %@",
error.localizedDescription
)
DispatchQueue.main.async { DispatchQueue.main.async {
self.reportFailure( self.presentFailure(error.localizedDescription)
self.sanitizedFailureMessage(error.localizedDescription),
shouldNotifyWizard: shouldNotifyWizard
)
} }
return
}
process.waitUntilExit()
readDone.wait()
guard process.terminationStatus == 0 else {
let message = String(data: outputData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
DispatchQueue.main.async {
self.reportFailure(
self.sanitizedFailureMessage(message),
shouldNotifyWizard: shouldNotifyWizard
)
}
return
}
DispatchQueue.main.async {
if shouldNotifyWizard {
EnrollmentCallbackStore.writeStatus(state: .success, message: nil)
}
NSApp.terminate(nil)
} }
} }
} }
private func reportFailure(_ message: String, shouldNotifyWizard: Bool = EnrollmentCallbackStore.isWizardRunning()) { private func presentFailure(_ message: String) {
if shouldNotifyWizard { presentAlert(title: "Enrollment failed", message: message, style: .warning)
EnrollmentCallbackStore.writeStatus(state: .failure, message: message)
NSApp.terminate(nil)
return
}
showError(message)
} }
private func sanitizedFailureMessage(_ raw: String?) -> String { private func presentAlert(title: String, message: String, style: NSAlert.Style) {
guard let raw else { // LSUIElement / .accessory apps otherwise show alerts that never appear.
return "Enrollment failed. Please try again." NSApp.setActivationPolicy(.regular)
}
let normalized = raw.lowercased()
if normalized.contains("already enrolled") {
return "This device is already enrolled."
}
if normalized.contains("key") && normalized.contains("missing") {
return "Device API key is missing or invalid."
}
return "Enrollment failed. Please try again."
}
private func showError(_ message: String) {
let alert = NSAlert()
alert.messageText = "Enrollment failed"
alert.informativeText = message
alert.alertStyle = .warning
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = style
alert.runModal() alert.runModal()
NSApp.setActivationPolicy(.accessory)
NSApp.terminate(nil) NSApp.terminate(nil)
} }
} }

View File

@@ -1,14 +1,18 @@
#!/bin/bash #!/bin/bash
# #
# Build Probo Agent.app — a headless macOS app bundle that registers # Build Probo Agent.app — headless macOS app bundle with probo:// handler
# the probo:// URL scheme and forwards enrollment links to probo-agent. # and embedded privileged helper (installed by PKG postinstall).
# #
# Required arguments: # Required arguments:
# --arch amd64, arm64, or universal # --arch amd64, arm64, or universal
# --version Agent version, e.g. 0.1.0 # --version Agent version, e.g. 0.1.0
# --output Parent directory; creates "Probo Agent.app" inside it # --output Parent directory; creates "Probo Agent.app" inside it
# #
# Must run on macOS with the Swift toolchain (swift build). # Required environment variables:
# CODESIGN_IDENTITY Developer ID Application identity. Signs the
# embedded helper (for SMPrivilegedExecutables DR),
# URL handler, and app bundle.
# APPLE_TEAM_ID Apple Developer Team ID for SMAuthorizedClients.
set -euo pipefail set -euo pipefail
@@ -18,7 +22,10 @@ ARCH=""
VERSION="" VERSION=""
OUTPUT="" OUTPUT=""
APP_NAME="Probo Agent.app" APP_NAME="Probo Agent.app"
EXECUTABLE_NAME="probo-agent-url-handler" URL_HANDLER_NAME="probo-agent-url-handler"
HELPER_LABEL="com.probo.agent.helper"
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-}"
APPLE_TEAM_ID="${APPLE_TEAM_ID:-}"
usage() { usage() {
sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0" sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0"
@@ -60,33 +67,123 @@ if ! command -v swift >/dev/null 2>&1; then
echo "error: swift is required (run on macOS with Xcode or Swift toolchain)" >&2 echo "error: swift is required (run on macOS with Xcode or Swift toolchain)" >&2
exit 1 exit 1
fi fi
if [ -z "${CODESIGN_IDENTITY}" ]; then
echo "error: CODESIGN_IDENTITY is required (privileged helper must be signed)" >&2
exit 2
fi
if [ -z "${APPLE_TEAM_ID}" ]; then
echo "error: APPLE_TEAM_ID is required (SMAuthorizedClients team requirement)" >&2
exit 2
fi
BUILD_DIR="$(mktemp -d -t probo-agent-url-handler-build)" BUILD_DIR="$(mktemp -d -t probo-agent-enroll-ui-build)"
RENDER_DIR="${BUILD_DIR}/rendered"
trap 'rm -rf "${BUILD_DIR}"' EXIT trap 'rm -rf "${BUILD_DIR}"' EXIT
mkdir -p "${RENDER_DIR}"
client_requirement() {
if [ -z "${APPLE_TEAM_ID}" ]; then
echo "error: APPLE_TEAM_ID is required (client designated requirement)" >&2
exit 2
fi
printf 'anchor apple generic and identifier "com.probo.agent.url-handler" and certificate leaf[subject.OU] = "%s"' "${APPLE_TEAM_ID}"
}
team_id_option() {
if [ -n "${APPLE_TEAM_ID}" ]; then
printf '"%s"' "${APPLE_TEAM_ID}"
return
fi
printf 'nil'
}
sed \
-e "s|@@VERSION@@|${VERSION}|g" \
"${SCRIPT_DIR}/Shared/HelperVersion.generated.swift.tmpl" \
> "${SCRIPT_DIR}/Shared/HelperVersion.generated.swift"
sed \
-e "s|@@TEAM_ID_OPTION@@|$(team_id_option)|g" \
"${SCRIPT_DIR}/Shared/SigningConstants.generated.swift.tmpl" \
> "${SCRIPT_DIR}/Shared/SigningConstants.generated.swift"
HELPER_INFO_PLIST="${RENDER_DIR}/helper-info.plist"
HELPER_LAUNCHD_PLIST="${RENDER_DIR}/helper-launchd.plist"
sed \
-e "s|@@VERSION@@|${VERSION}|g" \
-e "s|@@CLIENT_DESIGNATED_REQUIREMENT@@|$(client_requirement)|g" \
"${SCRIPT_DIR}/HelperTool/Info.plist.tmpl" > "${HELPER_INFO_PLIST}"
cp "${SCRIPT_DIR}/HelperTool/Launchd.plist.tmpl" "${HELPER_LAUNCHD_PLIST}"
HELPER_LINKER_FLAGS=(
-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist
-Xlinker "${HELPER_INFO_PLIST}"
-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __launchd_plist
-Xlinker "${HELPER_LAUNCHD_PLIST}"
)
pushd "${SCRIPT_DIR}" >/dev/null pushd "${SCRIPT_DIR}" >/dev/null
swift build -c release "${SWIFT_ARCH_ARGS[@]}" --scratch-path "${BUILD_DIR}" swift build -c release "${SWIFT_ARCH_ARGS[@]}" \
BIN_DIR="$(swift build -c release "${SWIFT_ARCH_ARGS[@]}" --scratch-path "${BUILD_DIR}" --show-bin-path)" --scratch-path "${BUILD_DIR}/swift" \
BINARY="${BIN_DIR}/${EXECUTABLE_NAME}" --product "${HELPER_LABEL}" \
"${HELPER_LINKER_FLAGS[@]}"
swift build -c release "${SWIFT_ARCH_ARGS[@]}" \
--scratch-path "${BUILD_DIR}/swift" \
--product "${URL_HANDLER_NAME}"
BIN_DIR="$(swift build -c release "${SWIFT_ARCH_ARGS[@]}" \
--scratch-path "${BUILD_DIR}/swift" --show-bin-path)"
HELPER_BINARY="${BIN_DIR}/${HELPER_LABEL}"
URL_HANDLER_BINARY="${BIN_DIR}/${URL_HANDLER_NAME}"
popd >/dev/null popd >/dev/null
if [ ! -x "${BINARY}" ]; then if [ ! -x "${HELPER_BINARY}" ] || [ ! -x "${URL_HANDLER_BINARY}" ]; then
echo "error: release binary not found at ${BINARY}" >&2 echo "error: expected release binaries were not produced" >&2
exit 1 exit 1
fi fi
APP_ROOT="${OUTPUT}/${APP_NAME}" APP_ROOT="${OUTPUT}/${APP_NAME}"
CONTENTS="${APP_ROOT}/Contents" CONTENTS="${APP_ROOT}/Contents"
MACOS="${CONTENTS}/MacOS" MACOS="${CONTENTS}/MacOS"
LAUNCH_SERVICES="${CONTENTS}/Library/LaunchServices"
LAUNCH_DAEMONS="${CONTENTS}/Library/LaunchDaemons"
PLIST="${CONTENTS}/Info.plist" PLIST="${CONTENTS}/Info.plist"
EMBEDDED_HELPER="${LAUNCH_SERVICES}/${HELPER_LABEL}"
EMBEDDED_LAUNCHD="${LAUNCH_DAEMONS}/${HELPER_LABEL}.plist"
rm -rf "${APP_ROOT}" rm -rf "${APP_ROOT}"
mkdir -p "${MACOS}" mkdir -p "${MACOS}" "${LAUNCH_SERVICES}" "${LAUNCH_DAEMONS}"
install -m 0755 "${BINARY}" "${MACOS}/${EXECUTABLE_NAME}" install -m 0755 "${URL_HANDLER_BINARY}" "${MACOS}/${URL_HANDLER_NAME}"
install -m 0755 "${HELPER_BINARY}" "${EMBEDDED_HELPER}"
install -m 0644 "${HELPER_LAUNCHD_PLIST}" "${EMBEDDED_LAUNCHD}"
codesign \
--force \
--options runtime \
--timestamp \
--sign "${CODESIGN_IDENTITY}" \
"${EMBEDDED_HELPER}"
codesign --verify --verbose=2 "${EMBEDDED_HELPER}"
# codesign prints "Executable=…" on stderr and either
# "# designated => …" (modern) or "designated => …" (older) on stdout.
HELPER_REQUIREMENT="$(
codesign -d -r- "${EMBEDDED_HELPER}" 2>&1 \
| sed -n -e 's/^# designated => //p' -e 's/^designated => //p'
)"
if [ -z "${HELPER_REQUIREMENT}" ]; then
echo "error: cannot extract designated requirement from signed helper" >&2
codesign -d -r- "${EMBEDDED_HELPER}" 2>&1 >&2 || true
exit 1
fi
echo "Helper designated requirement: ${HELPER_REQUIREMENT}"
sed \ sed \
-e "s|@@VERSION@@|${VERSION}|g" \ -e "s|@@VERSION@@|${VERSION}|g" \
-e "s|@@HELPER_DESIGNATED_REQUIREMENT@@|${HELPER_REQUIREMENT}|g" \
"${SCRIPT_DIR}/Info.plist.tmpl" > "${PLIST}" "${SCRIPT_DIR}/Info.plist.tmpl" > "${PLIST}"
if ! plutil -lint "${PLIST}" >/dev/null; then if ! plutil -lint "${PLIST}" >/dev/null; then
@@ -98,4 +195,18 @@ if ! grep -q '<string>probo</string>' "${PLIST}"; then
exit 1 exit 1
fi fi
codesign \
--force \
--options runtime \
--timestamp \
--sign "${CODESIGN_IDENTITY}" \
"${MACOS}/${URL_HANDLER_NAME}"
codesign \
--force \
--options runtime \
--timestamp \
--sign "${CODESIGN_IDENTITY}" \
"${APP_ROOT}"
codesign --verify --verbose=2 "${APP_ROOT}"
echo "Built ${APP_ROOT}" echo "Built ${APP_ROOT}"

View File

@@ -0,0 +1,26 @@
#!/bin/bash
#
# Tear down any previous macOS install, then install a signed .pkg.
# Must run as root. Used by: make -C cmd/probo-agent install
#
# Usage: reinstall.sh /path/to/probo-agent_*.pkg
set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PKG="${1:-}"
if [ "$(id -u)" -ne 0 ]; then
echo "error: must run as root (try: sudo make -C cmd/probo-agent install)" >&2
exit 1
fi
if [ -z "${PKG}" ] || [ ! -f "${PKG}" ]; then
echo "error: usage: $0 /path/to/probo-agent_*.pkg" >&2
exit 2
fi
"${SCRIPT_DIR}/uninstall.sh"
echo "Installing ${PKG}"
installer -pkg "${PKG}" -target /
echo "PKG install complete."

View File

@@ -22,6 +22,10 @@ STATE_DIR="/var/lib/probo-agent"
RUN_DIR="/var/run/probo-agent" RUN_DIR="/var/run/probo-agent"
CONF_FILE="/tmp/probo-agent.conf" CONF_FILE="/tmp/probo-agent.conf"
DAEMON_PLIST="/Library/LaunchDaemons/com.probo.agent.plist" DAEMON_PLIST="/Library/LaunchDaemons/com.probo.agent.plist"
HELPER_LABEL="com.probo.agent.helper"
HELPER_PLIST="/Library/LaunchDaemons/${HELPER_LABEL}.plist"
HELPER_BINARY="/Library/PrivilegedHelperTools/${HELPER_LABEL}"
APP_PATH="/Applications/Probo Agent.app"
TRAY_LABEL="com.probo.agent.tray" TRAY_LABEL="com.probo.agent.tray"
TRAY_PLIST_NAME="${TRAY_LABEL}.plist" TRAY_PLIST_NAME="${TRAY_LABEL}.plist"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -133,11 +137,10 @@ register_tray_launchagent() {
} }
register_enrollment_url_scheme() { register_enrollment_url_scheme() {
local app_path lsregister local lsregister
app_path="/Applications/Probo Agent.app" if [ ! -d "${APP_PATH}" ]; then
if [ ! -d "${app_path}" ]; then echo "warning: ${APP_PATH} not found; cannot register probo:// URL scheme."
echo "warning: ${app_path} not found; cannot register probo:// URL scheme."
return 0 return 0
fi fi
@@ -147,7 +150,7 @@ register_enrollment_url_scheme() {
return 0 return 0
fi fi
if ! "${lsregister}" -f "${app_path}"; then if ! "${lsregister}" -f "${APP_PATH}"; then
echo "warning: failed to register probo:// URL scheme." echo "warning: failed to register probo:// URL scheme."
return 0 return 0
fi fi
@@ -155,6 +158,62 @@ register_enrollment_url_scheme() {
echo "Registered probo:// URL scheme." echo "Registered probo:// URL scheme."
} }
# Install the privileged helper as root during PKG install so browser
# enrollment can use XPC without SMJobBless / an admin password prompt.
install_privileged_helper() {
local src_helper="${APP_PATH}/Contents/Library/LaunchServices/${HELPER_LABEL}"
if [ ! -x "${src_helper}" ]; then
echo "error: privileged helper missing at ${src_helper}"
return 1
fi
mkdir -p /Library/PrivilegedHelperTools /Library/LaunchDaemons
if [ -f "${HELPER_PLIST}" ]; then
launchctl bootout system "${HELPER_PLIST}" 2>/dev/null || true
fi
# Match SMJobBless-style permissions (root:wheel, not world-writable).
install -m 0544 -o root -g wheel "${src_helper}" "${HELPER_BINARY}"
cat > "${HELPER_PLIST}" <<EOF
<?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>${HELPER_LABEL}</string>
<key>Program</key>
<string>${HELPER_BINARY}</string>
<key>ProgramArguments</key>
<array>
<string>${HELPER_BINARY}</string>
</array>
<key>MachServices</key>
<dict>
<key>${HELPER_LABEL}</key>
<true/>
</dict>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>com.probo.agent.url-handler</string>
</array>
</dict>
</plist>
EOF
chmod 0644 "${HELPER_PLIST}"
chown root:wheel "${HELPER_PLIST}"
if ! launchctl bootstrap system "${HELPER_PLIST}"; then
echo "warning: could not bootstrap ${HELPER_LABEL}; first XPC connect may start it."
return 0
fi
echo "Installed privileged helper at ${HELPER_BINARY}."
return 0
}
# Restart a previously enrolled LaunchDaemon after upgrades. Preinstall # Restart a previously enrolled LaunchDaemon after upgrades. Preinstall
# boots it out so the binary can be replaced; without /tmp/probo-agent.conf # boots it out so the binary can be replaced; without /tmp/probo-agent.conf
# enrollment is skipped and nothing else would load it again. # enrollment is skipped and nothing else would load it again.
@@ -246,6 +305,11 @@ else
echo "No ${CONF_FILE} found; enrollment can be completed from the menu bar icon." echo "No ${CONF_FILE} found; enrollment can be completed from the menu bar icon."
fi fi
if ! install_privileged_helper; then
echo "error: privileged helper installation failed; browser enrollment will not work."
exit 1
fi
restart_existing_daemon restart_existing_daemon
register_tray_launchagent register_tray_launchagent
register_enrollment_url_scheme register_enrollment_url_scheme

View File

@@ -12,6 +12,9 @@ LOG_FILE="/var/log/probo-agent-install.log"
TRAY_LABEL="com.probo.agent.tray" TRAY_LABEL="com.probo.agent.tray"
TRAY_PLIST="/Library/LaunchAgents/${TRAY_LABEL}.plist" TRAY_PLIST="/Library/LaunchAgents/${TRAY_LABEL}.plist"
DAEMON_PLIST="/Library/LaunchDaemons/com.probo.agent.plist" DAEMON_PLIST="/Library/LaunchDaemons/com.probo.agent.plist"
HELPER_LABEL="com.probo.agent.helper"
HELPER_PLIST="/Library/LaunchDaemons/${HELPER_LABEL}.plist"
HELPER_BINARY="/Library/PrivilegedHelperTools/${HELPER_LABEL}"
mkdir -p "$(dirname "${LOG_FILE}")" mkdir -p "$(dirname "${LOG_FILE}")"
exec > >(tee -a "${LOG_FILE}") 2>&1 exec > >(tee -a "${LOG_FILE}") 2>&1
@@ -54,6 +57,17 @@ if [ -f "${DAEMON_PLIST}" ]; then
echo "Booted out LaunchDaemon at ${DAEMON_PLIST}." echo "Booted out LaunchDaemon at ${DAEMON_PLIST}."
fi fi
if [ -f "${HELPER_PLIST}" ]; then
launchctl bootout system "${HELPER_PLIST}" 2>/dev/null || true
rm -f "${HELPER_PLIST}"
echo "Removed privileged helper LaunchDaemon at ${HELPER_PLIST}."
fi
if [ -f "${HELPER_BINARY}" ]; then
rm -f "${HELPER_BINARY}"
echo "Removed privileged helper binary at ${HELPER_BINARY}."
fi
if [ -f "${TRAY_PLIST}" ]; then if [ -f "${TRAY_PLIST}" ]; then
echo "Existing tray LaunchAgent will be replaced by postinstall." echo "Existing tray LaunchAgent will be replaced by postinstall."
fi fi

View File

@@ -0,0 +1,149 @@
#!/bin/bash
#
# Fully remove a macOS PKG install of probo-agent.
#
# Idempotent: missing pieces are skipped. Must run as root.
#
# Removes:
# - agent service, tray LaunchAgent, privileged helper
# - binary, Probo Agent.app (and .localized variants)
# - state/run dirs, logs
# - PKG receipt (com.probo.agent)
# - stale Launch Services registration for the URL handler
set -u
BINARY="/usr/local/bin/probo-agent"
STATE_DIR="/var/lib/probo-agent"
RUN_DIR="/var/run/probo-agent"
DAEMON_PLIST="/Library/LaunchDaemons/com.probo.agent.plist"
HELPER_LABEL="com.probo.agent.helper"
HELPER_PLIST="/Library/LaunchDaemons/${HELPER_LABEL}.plist"
HELPER_BINARY="/Library/PrivilegedHelperTools/${HELPER_LABEL}"
TRAY_LABEL="com.probo.agent.tray"
TRAY_PLIST="/Library/LaunchAgents/${TRAY_LABEL}.plist"
PKG_ID="com.probo.agent"
LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
log() {
printf '%s\n' "$*"
}
die() {
printf 'error: %s\n' "$*" >&2
exit 1
}
require_root() {
if [ "$(id -u)" -ne 0 ]; then
die "must run as root (try: sudo make -C cmd/probo-agent uninstall)"
fi
}
bootout_system_plist() {
local plist="$1"
if [ -f "${plist}" ]; then
launchctl bootout system "${plist}" 2>/dev/null || true
log "Booted out ${plist}"
fi
}
bootout_tray_for_user() {
local username="$1"
local user_uid
if [ -z "${username}" ] || \
[ "${username}" = "root" ] || \
[ "${username}" = "loginwindow" ]; then
return 0
fi
user_uid="$(id -u "${username}" 2>/dev/null || true)"
if [ -z "${user_uid}" ]; then
return 0
fi
launchctl bootout "gui/${user_uid}/${TRAY_LABEL}" 2>/dev/null || true
}
unregister_apps() {
local path
for path in \
"/Applications/Probo Agent.app" \
"/Applications/Probo Agent.localized/Probo Agent.app"
do
if [ -d "${path}" ] && [ -x "${LSREGISTER}" ]; then
"${LSREGISTER}" -u "${path}" 2>/dev/null || true
log "Unregistered Launch Services entry for ${path}"
fi
done
}
kill_leftovers() {
# Best-effort; deleted-but-running binaries otherwise keep claiming probo://.
pkill -x probo-agent-url-handler 2>/dev/null || true
pkill -f '/usr/local/bin/probo-agent tray' 2>/dev/null || true
pkill -f '/Library/PrivilegedHelperTools/com.probo.agent.helper' 2>/dev/null || true
# Agent daemon may still be running after plist bootout races.
pkill -x probo-agent 2>/dev/null || true
}
require_root
if [ "$(uname -s)" != "Darwin" ]; then
die "this uninstall script is macOS-only"
fi
log "=== probo-agent macOS uninstall $(date -u +%Y-%m-%dT%H:%M:%SZ) ==="
# Prefer the agent's own uninstall for service/tray/state when present.
if [ -x "${BINARY}" ]; then
if "${BINARY}" uninstall; then
log "Ran: ${BINARY} uninstall"
else
log "warning: ${BINARY} uninstall failed; continuing with manual cleanup"
fi
else
log "Binary not found at ${BINARY}; skipping probo-agent uninstall"
fi
seen_users=" "
for username in $(users 2>/dev/null || true); do
case "${seen_users}" in
*" ${username} "*) continue ;;
esac
seen_users="${seen_users}${username} "
bootout_tray_for_user "${username}"
done
bootout_tray_for_user "$(stat -f "%Su" /dev/console 2>/dev/null || true)"
bootout_system_plist "${DAEMON_PLIST}"
bootout_system_plist "${HELPER_PLIST}"
kill_leftovers
rm -f "${DAEMON_PLIST}" "${HELPER_PLIST}" "${HELPER_BINARY}" "${TRAY_PLIST}"
log "Removed LaunchDaemon / LaunchAgent / helper files (if present)"
unregister_apps
rm -rf \
"/Applications/Probo Agent.app" \
"/Applications/Probo Agent.localized"
log "Removed Probo Agent.app (if present)"
rm -f "${BINARY}"
rm -rf "${STATE_DIR}" "${RUN_DIR}"
rm -f \
/var/log/probo-agent.log \
/var/log/probo-agent-install.log \
/tmp/probo-agent.conf
log "Removed binary, state, run dir, logs, and staged conf (if present)"
if pkgutil --pkg-info "${PKG_ID}" >/dev/null 2>&1; then
if ! pkgutil --forget "${PKG_ID}" >/dev/null; then
die "failed to forget PKG receipt ${PKG_ID}"
fi
log "Forgot PKG receipt ${PKG_ID}"
fi
log "=== uninstall done ==="

View File

@@ -25,9 +25,11 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"runtime"
"syscall" "syscall"
"time" "time"
@@ -100,6 +102,8 @@ func newRootCmd() *cobra.Command {
} }
func newEnrollURLCmd() *cobra.Command { func newEnrollURLCmd() *cobra.Command {
var preflight bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "enroll-url [url]", Use: "enroll-url [url]",
Hidden: true, Hidden: true,
@@ -112,14 +116,32 @@ func newEnrollURLCmd() *cobra.Command {
dir := resolveDir(cmd) dir := resolveDir(cmd)
if preflight {
enrolled, err := deviceagent.IsEnrolled(deviceagent.EnrollmentRunDir(dir))
if err != nil {
return fmt.Errorf("cannot check enrollment state: %w", err)
}
return writeEnrollPreflight(cmd.OutOrStdout(), serverURL, enrollmentToken, dir, enrolled)
}
already, err := reportIfAlreadyEnrolled(dir) already, err := reportIfAlreadyEnrolled(dir)
if err != nil { if err != nil {
return err return err
} }
if already { if already {
return nil return nil
} }
if runtime.GOOS == "darwin" {
return fmt.Errorf(
"macOS browser enrollment must use the signed Probo Agent.app " +
"(probo:// deeplink); for CLI use: sudo probo-agent install " +
"--server … --enrollment-token …",
)
}
exePath, err := os.Executable() exePath, err := os.Executable()
if err != nil { if err != nil {
return fmt.Errorf("cannot resolve current executable path: %w", err) return fmt.Errorf("cannot resolve current executable path: %w", err)
@@ -135,9 +157,42 @@ func newEnrollURLCmd() *cobra.Command {
}, },
} }
cmd.Flags().BoolVar(&preflight, "preflight", false, "validate enrollment URL and print JSON for the macOS URL handler")
return cmd return cmd
} }
type enrollPreflightResponse struct {
Server string `json:"server"`
Token string `json:"token"`
AlreadyEnrolled bool `json:"alreadyEnrolled"`
ConfigDir string `json:"configDir"`
}
func writeEnrollPreflight(
w io.Writer,
serverURL, enrollmentToken, dir string,
alreadyEnrolled bool,
) error {
payload := enrollPreflightResponse{
Server: serverURL,
Token: enrollmentToken,
AlreadyEnrolled: alreadyEnrolled,
ConfigDir: dir,
}
out, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("cannot encode enrollment preflight response: %w", err)
}
if _, err := fmt.Fprintln(w, string(out)); err != nil {
return fmt.Errorf("cannot write enrollment preflight response: %w", err)
}
return nil
}
// reportIfAlreadyEnrolled prints a success message and returns true when // reportIfAlreadyEnrolled prints a success message and returns true when
// the local enrollment marker is already present. Deep-link retries must // the local enrollment marker is already present. Deep-link retries must
// exit 0 so the macOS URL handler does not show "Enrollment failed". // exit 0 so the macOS URL handler does not show "Enrollment failed".
@@ -231,6 +286,7 @@ func newInstallCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
if already { if already {
return nil return nil
} }

View File

@@ -14,8 +14,10 @@ The project uses a `GNUmakefile` at the root. Builds run with `--jobs=$(nproc)`
| `make test-short` | Short tests only | | `make test-short` | Short tests only |
| `make test-bench` | Run benchmarks | | `make test-bench` | Run benchmarks |
| `make test-e2e` | Run console end-to-end tests (requires `bin/probod`) | | `make test-e2e` | Run console end-to-end tests (requires `bin/probod`) |
| `make lint` | Run all linters: `vet` + `go-fmt` + `go-fix` + `go-lint` + `lint-js` | | `make lint` | Run Go + JS linters: `vet` + `go-fmt` + `go-fix` + `go-lint` + `lint-js` |
| `make fmt` | Format Go code (`go fmt ./...`) | | `make lint-swift` | Opt-in: lint Swift enroll-ui (`swift-fmt` + `swift-lint`; needs Swift + SwiftLint; CI runs this on Linux) |
| `make fmt` | Format Go code |
| `make fmt-swift` | Opt-in: format Swift enroll-ui (`swift format` + SwiftLint `--fix`; needs Swift) |
| `make clean` | Remove all build artifacts, `node_modules`, generated files, and coverage | | `make clean` | Remove all build artifacts, `node_modules`, generated files, and coverage |
| `make help` | List targets with `##` doc comments | | `make help` | List targets with `##` doc comments |
@@ -81,3 +83,6 @@ Individual codegen is driven by `go generate`:
| `GOOS` | (host) | Cross-compile target OS | | `GOOS` | (host) | Cross-compile target OS |
| `TEST_FLAGS` | `-race -cover -coverprofile=coverage.out` | Extra flags passed to `go test` | | `TEST_FLAGS` | `-race -cover -coverprofile=coverage.out` | Extra flags passed to `go test` |
| `DOCKER_BUILD_FLAGS` | (empty) | Extra flags for `docker build` | | `DOCKER_BUILD_FLAGS` | (empty) | Extra flags for `docker build` |
| `SWIFTLINTCMD` | `swiftlint` | SwiftLint binary |
| `SWIFTCMD` | `swift` | Swift toolchain binary (`swift format`) |
| `SWIFT_ENROLL_UI` | `cmd/probo-agent/installer/macos/enroll-ui` | Path to the Swift SPM package |

View File

@@ -28,9 +28,9 @@ If empty or non-user-facing only, do not release this track.
make bin/probo-agent make bin/probo-agent
``` ```
On macOS and Windows hosts, `make` enables CGO automatically so the On macOS hosts, `make` enables CGO so the menu bar enrollment helper
menu bar / tray enrollment helper is included. Linux and FreeBSD builds is included. Windows tray support is pure Go (`CGO_ENABLED=0`). Linux
stay pure Go (no tray). and FreeBSD builds stay pure Go (no tray).
## Notes ## Notes
@@ -45,27 +45,34 @@ verifies the cosign bundle before installing.
The menu bar / tray enrollment flow is **macOS and Windows only**. The menu bar / tray enrollment flow is **macOS and Windows only**.
Linux and FreeBSD use `probo-agent install --server … Linux and FreeBSD use `probo-agent install --server …
--enrollment-token …` from the shell, or the curl-to-sh installer --enrollment-token …` from the shell, or the curl-to-sh installer
documented below. Windows release binaries are documented below. Windows release binaries are cross-compiled from
cross-compiled from Linux with MinGW (CGO). macOS release binaries and Linux with `CGO_ENABLED=0` (tray is pure Go). macOS release binaries
the `.pkg` are built on macOS with `CGO_ENABLED=1`. and the `.pkg` are built on macOS with `CGO_ENABLED=1`.
### macOS `.pkg` (MDM / GUI install) ### macOS `.pkg` (MDM / GUI install)
Release and local builds use Release and local builds use
`cmd/probo-agent/installer/macos/build.sh` (requires macOS, a `cmd/probo-agent/installer/macos/build.sh` (requires macOS, a
pre-built binary — preferably universal via `lipo` — and the Swift pre-built binary — preferably universal via `lipo` — and the Swift
toolchain). The script compiles `Probo Agent.app` (the headless toolchain). **Signing is mandatory:** `CODESIGN_IDENTITY` and
`probo://` URL handler) from `APPLE_TEAM_ID` must be set. The script compiles `Probo Agent.app`
`cmd/probo-agent/installer/macos/enroll-ui/`, signs the binary and app (the headless `probo://` URL handler + privileged helper) from
when `CODESIGN_IDENTITY` is set, signs the product with `cmd/probo-agent/installer/macos/enroll-ui/`, signs the binary and
`INSTALLER_IDENTITY`, and notarizes/staples when app, optionally signs the product with `INSTALLER_IDENTITY`, and
`NOTARYTOOL_KEYCHAIN_PROFILE` is set, or when `APPLE_ID`, notarizes/staples when `APPLE_ID` and `APPLE_ID_PASSWORD` are set
`APPLE_ID_PASSWORD`, and `APPLE_TEAM_ID` are set (password is stored (password is stored into a keychain profile; submits use
into a keychain profile; submits use `--keychain-profile` so the secret `--keychain-profile` so the secret is not on `notarytool submit` argv).
is not on `notarytool submit` argv).
There is no unsigned PKG path and no osascript elevation fallback.
Local testing of browser enrollment requires a Developer IDsigned
build. CLI enrollment without the app uses `sudo probo-agent install`.
```shell ```shell
# Local unsigned universal pkg (example) # Local signed pkg (example)
export CODESIGN_IDENTITY="Developer ID Application: Probo Inc (TEAMID)"
export INSTALLER_IDENTITY="Developer ID Installer: Probo Inc (TEAMID)"
export APPLE_TEAM_ID="TEAMID"
GOOS=darwin GOARCH=arm64 CGO_ENABLED=1 go build -o dist/probo-agent_arm64 ./cmd/probo-agent GOOS=darwin GOARCH=arm64 CGO_ENABLED=1 go build -o dist/probo-agent_arm64 ./cmd/probo-agent
GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -o dist/probo-agent_amd64 ./cmd/probo-agent GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -o dist/probo-agent_amd64 ./cmd/probo-agent
lipo -create dist/probo-agent_arm64 dist/probo-agent_amd64 -output dist/probo-agent_universal lipo -create dist/probo-agent_arm64 dist/probo-agent_amd64 -output dist/probo-agent_universal
@@ -75,11 +82,29 @@ cmd/probo-agent/installer/macos/build.sh \
--version "$(cat cmd/probo-agent/VERSION)" --version "$(cat cmd/probo-agent/VERSION)"
``` ```
PKG postinstall always installs the global tray LaunchAgent and PKG postinstall always installs the global tray LaunchAgent, registers
registers `probo://`. The LaunchDaemon for `probo-agent run` is created `probo://`, and installs the privileged helper
only after enrollment (`probo-agent install`, deep link, or MDM (`com.probo.agent.helper`) under `/Library/PrivilegedHelperTools` as
root. The only admin authentication is the normal macOS Installer
prompt for the PKG itself. The LaunchDaemon for `probo-agent run` is
created only after enrollment (`probo-agent install`, deep link, or MDM
`/tmp/probo-agent.conf`). `/tmp/probo-agent.conf`).
Browser enrollment uses `Probo Agent.app` over XPC to the
PKG-installed helper — no SMJobBless and no admin prompt on the enroll
path. A missing or dead helper surfaces an error asking to reinstall
the package.
Manual QA checklist (macOS PKG):
1. Fresh signed PKG: Installer may ask for admin once; deep link enrolls with no further prompt.
2. Repeat deep link on an enrolled device: no prompt, immediate success.
3. After `sudo make -C cmd/probo-agent uninstall`, deep link fails with a clear “reinstall package” error (no osascript).
4. `sudo make -C cmd/probo-agent uninstall` removes daemon, tray, helper, app, and state.
5. MDM `/tmp/probo-agent.conf` postinstall still enrolls without a browser prompt.
6. Notarized PKG passes Gatekeeper; `codesign --verify --deep` succeeds on the app bundle.
7. Unsigned `build-app.sh` / `build.sh` exits with an error requiring `CODESIGN_IDENTITY`.
### Apple signing secrets (GitHub) ### Apple signing secrets (GitHub)
The `build-macos` job in `release-probo-agent.yaml` expects the same The `build-macos` job in `release-probo-agent.yaml` expects the same
@@ -97,24 +122,23 @@ the probo GitHub repository (or org) before tagging a release:
| `APPLE_ID_PASSWORD` | App-specific password (stored into a keychain profile; not passed to `submit`) | | `APPLE_ID_PASSWORD` | App-specific password (stored into a keychain profile; not passed to `submit`) |
| `APPLE_TEAM_ID` | 10-character Team ID | | `APPLE_TEAM_ID` | 10-character Team ID |
Local notarization can reuse a pre-stored profile instead of putting the Local notarization uses the same env vars as CI:
password in the environment:
```shell ```shell
xcrun notarytool store-credentials probo-agent-notary \ export APPLE_ID="you@example.com"
--apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" export APPLE_ID_PASSWORD="app-specific-password"
# prompts for the app-specific password once # optional: NOTARYTOOL_KEYCHAIN_PROFILE=probo-agent-notary (default)
export NOTARYTOOL_KEYCHAIN_PROFILE=probo-agent-notary
``` ```
Windows enrollment is browser-driven: the console issues a Windows enrollment is browser-driven: the console issues a
`probo://enroll?server=...&token=...` deep link handled by `probo://enroll?server=...&token=...` deep link handled by
`probo-agent enroll-url`. After install, register the protocol for the `Probo Agent.app` on macOS (PKG-installed helper + XPC) or
`probo-agent enroll-url` on Windows. After install, register the protocol for the
current user with current user with
`cmd/probo-agent/installer/windows/register-protocol.ps1` (per-user `cmd/probo-agent/installer/windows/register-protocol.ps1` (per-user
`HKCU` handler pointing at `probo-agent.exe`). The system tray helper `HKCU` handler pointing at `probo-agent.exe`). The system tray helper
(`probo-agent tray`, CGO build on local Windows hosts) shows enrollment (`probo-agent tray`) shows enrollment status; enrollment itself happens
status; enrollment itself happens in the browser. in the browser.
Region labels and console URLs for the macOS installer HTML live in Region labels and console URLs for the macOS installer HTML live in
`cmd/probo-agent/installer/regions.json`. A Go test keeps US/EU URLs in `cmd/probo-agent/installer/regions.json`. A Go test keeps US/EU URLs in
@@ -165,34 +189,32 @@ Environment variables:
Never pass the enrollment token in the curl URL. Never pass the enrollment token in the curl URL.
## Local dev install ## Local install (macOS PKG)
Build, package, and install a dev binary with `sudo`. Agent state is stored Primary loop for testing browser enrollment (app + privileged helper):
under `~/.local/share/probo-agent-dev` (`--dir`); release archives are staged
under `~/.cache/probo-agent-dev` so the two trees do not overlap.
```shell ```shell
make -C cmd/probo-agent install \ export CODESIGN_IDENTITY="Developer ID Application: … (TEAMID)"
export INSTALLER_IDENTITY="Developer ID Installer: … (TEAMID)" # optional
export APPLE_TEAM_ID="TEAMID"
make -C cmd/probo-agent install # uninstall leftovers → build PKG → installer
make -C cmd/probo-agent uninstall # full system teardown (idempotent)
make -C cmd/probo-agent clean # uninstall + wipe dist/ caches / enroll-ui/.build
```
`install` always runs `uninstall` first so previous helpers, apps, and
Launch Services registrations cannot shadow the new build. Signing env
vars are required for `pkg` / `install` on Darwin.
### CLI-only install (no app / helper)
Binary-only path via `installer/install.sh` (any Unix). State under
`~/.local/share/probo-agent-dev`; staging under `~/.cache/probo-agent-dev`.
```shell
make -C cmd/probo-agent install-cli \
PROBO_SERVER_URL=https://us.probo.com \ PROBO_SERVER_URL=https://us.probo.com \
PROBO_ENROLLMENT_TOKEN='…' PROBO_ENROLLMENT_TOKEN='…'
```
This does not compile Go in `cmd/probo-agent`; it reuses `bin/probo-agent` from
the root `GNUmakefile` (via `make bin/probo-agent`, invoked automatically when
needed), stages it into a local release archive, then runs `installer/install.sh`
against that `file://` archive with `PROBO_AGENT_RELEASE_TAG=probo-agent/dev`,
skips checksum verification, installs the binary to `/usr/local/bin/probo-agent`,
and passes `--skip-service` and `--dir ~/.local/share/probo-agent-dev` by default
(`INSTALL_ARGS` overrides).
Run the agent in the foreground against the same dev state directory:
```shell
make -C cmd/probo-agent run make -C cmd/probo-agent run
``` ```
Remove dev artifacts:
```shell
make -C cmd/probo-agent clean
```

View File

@@ -32,6 +32,12 @@ type InstallOptions struct {
ConfigDir string // agent config / keystore dir (--dir) ConfigDir string // agent config / keystore dir (--dir)
} }
// UninstallOptions configures an elevated probo-agent uninstall invocation.
type UninstallOptions struct {
ExePath string
ConfigDir string
}
func commandError(out []byte, err error) error { func commandError(out []byte, err error) error {
if err == nil { if err == nil {
return nil return nil

View File

@@ -22,51 +22,22 @@
package elevate package elevate
import ( import "errors"
"fmt"
"os/exec"
"strings"
"go.probo.inc/probo/pkg/deviceagent/checks" // ErrPrivilegedHelperRequired is returned when a non-root process asks for
// elevation on macOS. Browser enrollment must go through the signed
// Probo Agent.app XPC helper (installed by the PKG); CLI install/uninstall
// require sudo.
var ErrPrivilegedHelperRequired = errors.New(
"macOS elevation requires the signed Probo Agent.app privileged helper " +
"(browser enroll via PKG-installed helper) or sudo " +
"(CLI: sudo probo-agent install|uninstall)",
) )
func runElevatedInstall(opts InstallOptions, enrollmentToken string) error { func runElevatedInstall(_ InstallOptions, _ string) error {
parts := []string{ return ErrPrivilegedHelperRequired
shellQuote(opts.ExePath),
"install",
"--server",
shellQuote(opts.ServerURL),
"--enrollment-token",
shellQuote(enrollmentToken),
}
if opts.ConfigDir != "" {
parts = append(parts, "--dir", shellQuote(opts.ConfigDir))
}
shellCmd := strings.Join(parts, " ")
script := fmt.Sprintf(
`do shell script %s with administrator privileges`,
applescriptQuote(shellCmd),
)
candidates := checks.CommandCandidates("osascript")
if len(candidates) == 0 {
return fmt.Errorf("command %q not available at expected absolute path", "osascript")
}
out, err := exec.Command(candidates[0], "-e", script).CombinedOutput()
return commandError(out, err)
} }
func shellQuote(v string) string { func runElevatedUninstall(_ UninstallOptions) error {
return "'" + strings.ReplaceAll(v, "'", `'"'"'`) + "'" return ErrPrivilegedHelperRequired
}
func applescriptQuote(v string) string {
v = strings.ReplaceAll(v, `\`, `\\`)
v = strings.ReplaceAll(v, `"`, `\"`)
return `"` + v + `"`
} }

View File

@@ -0,0 +1,48 @@
// 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 elevate
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRunElevatedInstallRequiresPrivilegedHelper(t *testing.T) {
t.Parallel()
err := RunElevatedInstall(
"/usr/local/bin/probo-agent",
"https://example.com",
"token",
"/var/lib/probo-agent",
)
require.ErrorIs(t, err, ErrPrivilegedHelperRequired)
}
func TestRunElevatedUninstallRequiresPrivilegedHelper(t *testing.T) {
t.Parallel()
err := RunElevatedUninstall("/usr/local/bin/probo-agent", "/var/lib/probo-agent")
require.ErrorIs(t, err, ErrPrivilegedHelperRequired)
}

View File

@@ -68,3 +68,36 @@ func runElevatedInstall(opts InstallOptions, enrollmentToken string) error {
return commandError(out, err) return commandError(out, err)
} }
func runElevatedUninstall(opts UninstallOptions) error {
args := []string{"uninstall"}
if opts.ConfigDir != "" {
args = append(args, "--dir", opts.ConfigDir)
}
argList := make([]string, len(args))
for i, arg := range args {
argList[i] = "'" + escapePowerShellSingleQuoted(arg) + "'"
}
script := fmt.Sprintf(
`$p = Start-Process -FilePath %s -ArgumentList @(%s) -Verb RunAs -Wait -PassThru; if ($p.ExitCode -ne 0) { exit $p.ExitCode }`,
"'"+escapePowerShellSingleQuoted(opts.ExePath)+"'",
strings.Join(argList, ","),
)
candidates := checks.CommandCandidates("powershell.exe")
if len(candidates) == 0 {
return fmt.Errorf("command %q not available at expected absolute path", "powershell.exe")
}
out, err := exec.Command(
candidates[0],
"-NoProfile",
"-NonInteractive",
"-Command",
script,
).CombinedOutput()
return commandError(out, err)
}

View File

@@ -0,0 +1,29 @@
// 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 && !windows
package elevate
import "errors"
func RunElevatedUninstall(_ string, _ string) error {
return errors.New("elevated uninstall is only supported on macOS and Windows")
}

View File

@@ -0,0 +1,34 @@
// 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 elevate
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRunElevatedUninstallUnsupported(t *testing.T) {
t.Parallel()
err := RunElevatedUninstall("/usr/local/bin/probo-agent", "/var/lib/probo-agent")
require.Error(t, err)
}

View File

@@ -0,0 +1,32 @@
// 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 || windows
package elevate
func RunElevatedUninstall(exePath string, configDir string) error {
return runElevatedUninstall(
UninstallOptions{
ExePath: exePath,
ConfigDir: configDir,
},
)
}

View File

@@ -33,6 +33,10 @@ import (
const ( const (
plistPath = "/Library/LaunchDaemons/com.probo.agent.plist" plistPath = "/Library/LaunchDaemons/com.probo.agent.plist"
helperLabel = "com.probo.agent.helper"
helperPlistPath = "/Library/LaunchDaemons/" + helperLabel + ".plist"
helperBinaryPath = "/Library/PrivilegedHelperTools/" + helperLabel
) )
const launchdPlistTmpl = `<?xml version="1.0" encoding="UTF-8"?> const launchdPlistTmpl = `<?xml version="1.0" encoding="UTF-8"?>
@@ -83,6 +87,31 @@ func removeLaunchDaemonPlist(path string) error {
return nil return nil
} }
func removeManagedPath(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("cannot remove %s: %w", path, err)
}
return nil
}
// removePrivilegedHelper boots out and deletes the PKG-installed XPC helper.
// Missing artifacts are treated as success so uninstall stays idempotent.
func removePrivilegedHelper() error {
_ = exec.Command("launchctl", "bootout", "system/"+helperLabel).Run()
_ = exec.Command("launchctl", "bootout", "system", helperPlistPath).Run()
if err := removeManagedPath(helperPlistPath); err != nil {
return fmt.Errorf("cannot remove privileged helper plist: %w", err)
}
if err := removeManagedPath(helperBinaryPath); err != nil {
return fmt.Errorf("cannot remove privileged helper binary: %w", err)
}
return nil
}
// Install writes and boots the launchd plist. // Install writes and boots the launchd plist.
func Install(cfg Config) error { func Install(cfg Config) error {
if cfg.ExePath == "" { if cfg.ExePath == "" {
@@ -126,9 +155,18 @@ func Install(cfg Config) error {
return nil return nil
} }
// Uninstall bootouts and removes the launchd plist. // Uninstall bootouts and removes the agent LaunchDaemon and the privileged
// XPC helper installed by the macOS PKG.
func Uninstall(cfg Config) error { func Uninstall(cfg Config) error {
_ = cfg _ = cfg
return removeLaunchDaemonPlist(plistPath) if err := removeLaunchDaemonPlist(plistPath); err != nil {
return err
}
if err := removePrivilegedHelper(); err != nil {
return fmt.Errorf("cannot remove privileged helper: %w", err)
}
return nil
} }