From d88883d3919dedb7c19eb0cfaeddfa2a86896b58 Mon Sep 17 00:00:00 2001 From: Marc Went Date: Mon, 26 Oct 2026 21:19:54 +0100 Subject: [PATCH 1/2] feat(services): manage bundle-owned services explicitly - Add explicit installation and Service Management lifecycle for managed services - Keep normal app startup read-only and update packaging and delivery guidance Signed-off-by: Marc Went --- CONTRIBUTING.md | 8 +- README.md | 30 +- docs/apple-signing.md | 18 +- docs/handoffs/bundle-owned-services.md | 102 + .../sentinelone-detection-investigation.md | 184 ++ docs/homebrew-delivery.md | 12 +- docs/updates.md | 8 +- docs/upgrade-recovery.md | 10 +- internal/appservices/appservices.go | 286 +++ internal/appservices/appservices_test.go | 194 ++ internal/appservices/deadlines.go | 14 + internal/backend/common.go | 24 +- internal/backend/install.go | 147 +- internal/backend/install_managed.go | 1683 +++++++++++++++++ internal/backend/install_managed_copy.go | 441 +++++ internal/backend/install_managed_custody.go | 297 +++ .../backend/install_managed_custody_test.go | 288 +++ .../backend/install_managed_lifecycle_test.go | 636 +++++++ .../backend/install_managed_review_test.go | 492 +++++ .../backend/install_managed_shared_test.go | 417 ++++ internal/backend/install_managed_test.go | 722 +++++++ internal/backend/install_preflight.go | 28 +- internal/backend/install_private_services.go | 67 + .../backend/install_private_services_test.go | 122 ++ internal/backend/install_test.go | 61 +- .../backend/managed_runtime_fixture_test.go | 116 ++ internal/backend/queue_daemon.go | 9 +- internal/backend/scheduler_controller.go | 16 +- internal/backend/scheduler_shutdown_test.go | 173 ++ internal/backend/updates.go | 43 +- internal/remote/control.go | 86 +- internal/remote/managed_service.go | 86 + internal/remote/managed_service_test.go | 244 +++ internal/tooling/dmg_test.go | 23 +- internal/tooling/homebrew_lifecycle_test.go | 162 +- internal/tooling/installer.go | 106 +- internal/tooling/installer_test.go | 134 ++ .../tooling/managed_cask_contract_test.go | 190 ++ internal/tooling/managed_cask_runtime_test.go | 567 ++++++ internal/tooling/notarized_cask_test.go | 147 +- internal/tooling/signing_resume_test.go | 26 +- internal/tooling/signing_test.go | 93 + internal/tooling/tooling.go | 26 +- internal/tooling/tooling_test.go | 4 +- native/AgentBootstrap.swift | 144 +- native/AgentQueue.swift | 37 +- native/AgentServices.swift | 441 +++++ native/Installer.swift | 6 +- native/LaunchAgents/io.went.agent-heavy.plist | 12 + .../LaunchAgents/io.went.agent-remote.plist | 12 + .../io.went.agent-resources.plist | 11 + native/build.sh | 4 +- native/tests/BootstrapFixtures.swift | 62 +- native/tests/BootstrapTests.swift | 100 +- native/tests/BootstrapUITests.swift | 19 + native/tests/ServiceManagementTests.swift | 626 ++++++ native/tests/UpdateTests.swift | 12 +- native/tests/run-bootstrap-tests.sh | 4 +- native/tests/run-bootstrap-ui-tests.sh | 4 +- native/tests/run-menu-bar-ui-tests.sh | 4 +- native/tests/run-refresh-tests.sh | 4 +- native/tests/run-service-management-tests.sh | 18 + packaging/homebrew/refresh.go | 26 +- packaging/homebrew/refresh_test.go | 9 +- packaging/homebrew/test-cask.rb | 90 +- packaging/links/agent-heavy | 7 +- packaging/links/agent-remote | 7 +- scripts/build-installer.sh | 12 +- scripts/macos-resume.sh | 1 + scripts/macos-signing.sh | 53 +- scripts/test-dmg.sh | 9 +- scripts/test-installer.sh | 2 + scripts/test-native.sh | 17 +- scripts/test-notarized-installer.sh | 8 + 74 files changed, 9949 insertions(+), 354 deletions(-) create mode 100644 docs/handoffs/bundle-owned-services.md create mode 100644 docs/handoffs/sentinelone-detection-investigation.md create mode 100644 internal/appservices/appservices.go create mode 100644 internal/appservices/appservices_test.go create mode 100644 internal/appservices/deadlines.go create mode 100644 internal/backend/install_managed.go create mode 100644 internal/backend/install_managed_copy.go create mode 100644 internal/backend/install_managed_custody.go create mode 100644 internal/backend/install_managed_custody_test.go create mode 100644 internal/backend/install_managed_lifecycle_test.go create mode 100644 internal/backend/install_managed_review_test.go create mode 100644 internal/backend/install_managed_shared_test.go create mode 100644 internal/backend/install_managed_test.go create mode 100644 internal/backend/install_private_services.go create mode 100644 internal/backend/install_private_services_test.go create mode 100644 internal/backend/managed_runtime_fixture_test.go create mode 100644 internal/backend/scheduler_shutdown_test.go create mode 100644 internal/remote/managed_service.go create mode 100644 internal/remote/managed_service_test.go create mode 100644 internal/tooling/managed_cask_contract_test.go create mode 100644 internal/tooling/managed_cask_runtime_test.go create mode 100644 native/AgentServices.swift create mode 100644 native/LaunchAgents/io.went.agent-heavy.plist create mode 100644 native/LaunchAgents/io.went.agent-remote.plist create mode 100644 native/LaunchAgents/io.went.agent-resources.plist create mode 100644 native/tests/ServiceManagementTests.swift create mode 100755 native/tests/run-service-management-tests.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 916f340..23048a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,11 +35,11 @@ bash scripts/test-dmg.sh "dist/Agent-Job-Queue-Installer-$(cat VERSION)-universa Apply the shared queue policy to broad tests, builds, analyzers, browser suites, and installers. Keep the Go package and test worker limits at two unless explicitly authorized otherwise. Do not nest queue wrappers. Persistent preview servers stay outside the queue. -Core and HTTPS acceptance tests launch temporary private daemons, directories, child processes, ports, and test credentials. They remove inherited queue overrides before creating their fixture. Browser tests use simulated jobs through the actual Go HTTP interface. Installer and agent integration tests use isolated user directories. They must never operate on the installed queue or real agent instructions. The installer artifact test installs the actual packaged Go binaries into a temporary home and exercises their private queue and scheduler. +Core and HTTPS acceptance tests launch temporary private daemons, directories, child processes, ports, and test credentials. They remove inherited queue overrides before creating their fixture. Browser tests use simulated jobs through the actual Go HTTP interface. Installer and agent integration tests use isolated user directories. They must never operate on the installed queue or real agent instructions. The installer artifact test installs the actual packaged Go binaries into a temporary home and exercises their private queue and scheduler. It also runs the actual outer managed installer, setup and removal with a parent-owned native lifecycle fixture; a private HOME alone cannot isolate real Service Management. The disk-image check requires the DMG explicitly, mounts it read-only at a private mountpoint, and verifies the enclosed installer signatures, executable permissions, universal architectures, and build identities. A failed detach fails validation and retains its temporary directory. Packaging flushes staged files and disables indexing on its HFS+ image; verbose image-creation diagnostics remain available if macOS reports resource contention. -New disk images contain the self-contained `Agent Queue.app` and an `Applications` shortcut. The app embeds only the source support payload, with no recursive app bundle. First launch from the shared or user Applications folder installs its private backend in place. The installer ZIP retains its existing custom-installer layout for updater and Homebrew compatibility. Disk-image verification also supports retained legacy installer images. Validate a standalone app with `go run -p 2 ./cmd/queue-tool verify-app --app APP_PATH`. +New managed disk images contain the explicit installer app. The installed app carries its runnable helpers in `Contents/MacOS` and its three agent definitions in `Contents/Library/LaunchAgents`, using `BundleProgram`. Its support payload contains metadata and documents, with no recursive app or duplicate helper binaries. Normal launch performs readiness and authorization reads only. The ZIP retains its outer controller payload for updater and Homebrew compatibility. Homebrew delivers the explicit installer and links; the user opens it to install the runtime outside Homebrew's restricted hook sandbox. Homebrew upgrade or uninstall does not replace or remove the running runtime app. Disk-image and signature verification retain support for historical layouts. Validate a standalone app with `go run -p 2 ./cmd/queue-tool verify-app --app APP_PATH`. Local installer builds use ad hoc development signatures by default. Main CI validates and retains the universal installer. A separate main-only distribution workflow consumes that exact build, requires Developer ID signing, payload-first Apple notarization, stapled app and DMG tickets, and fresh Apple Silicon and Intel acceptance before publication and Homebrew delivery. Distribution fixes can process a retained build with newer trusted main automation. PRs receive no Apple secrets. See [Apple signing and notarization](docs/apple-signing.md) for credential custody, recovery commands and acceptance evidence. Never remove quarantine to make a notarized release pass. @@ -52,7 +52,7 @@ bash native/tests/run-menu-bar-ui-tests.sh build/menu-bar-verification.json The runner builds a private fixture app and sends AppKit events through the actual status button, menus, and window handlers. It records `skippedChecks` when macOS declines foreground activation. `--require-foreground` fails those unavailable checks. It cleans up its own fixture and preserves the installed app and clipboard. -First-launch setup decisions, retries, and guided upgrade recovery are included in the headless native suite. The read-only preflight lists blockers together and offers bounded waiting, reviewed job cancellation, verified cleanup and service startup, phone access disablement, and guided configuration repairs. See [guided upgrade recovery](docs/upgrade-recovery.md) for the action and controller contracts. In a logged-in graphical session, verify the production setup window, failed setup, Disable phone access and continue action, ordinary Retry button, and transition to the simulated queue: +Read-only startup, explicit `--setup` decisions, retries, approval states and guided recovery are included in the headless native suite. Normal launch cannot enter installation or recovery. The explicit setup workflow retains read-only preflight and reviewed recovery actions. Service-management tests inject inert adapters and never register real services. A temporary HOME alone does not isolate macOS's per-user Service Management registry; live registration acceptance needs an isolated login session. See [guided upgrade recovery](docs/upgrade-recovery.md). In a logged-in graphical session, the private fixture verifies the explicit setup window and transition to the simulated queue: ```sh agent-heavy run --label "First-launch UI checks" -- bash native/tests/run-bootstrap-ui-tests.sh build/bootstrap-ui-verification @@ -88,7 +88,7 @@ Native screenshot capture uses artificial jobs and an inert action recorder. It The CLI command names, JSON fields, SQLite schema and workload identities remain compatible with the Python implementation. Preserve lifecycle, cancellation, process ownership, crash recovery, authentication, scheduling, history and UI regression coverage. macOS resource observations use the same native Mach and libproc counters. Missing or stale telemetry cannot authorize adaptive work. -The first Pueue-to-embedded installation requires a fully drained private queue; migration from Python also requires disabled old remote access. When old phone access blocks first-launch setup, choose **Disable phone access and continue** to stop it through the bundled controller's ownership checks and resume installation. Credentials, configuration, and trusted certificates remain available; phone access can be enabled again in settings after setup. The CLI equivalent is the trailing `--disable-phone-access` option on `agent-heavy install --payload DIRECTORY` or `agent-heavy install --app-bundle ABSOLUTE_APP_PATH`. This recovery only disables phone access when it blocks Python migration. After ownership-checked shutdown, it imports completed tasks and the retained ID sequence into the new SQLite queue, then starts the independent Go daemon and resource controller. Later embedded queue upgrades retain active daemon jobs. An enabled Go remote service is restarted through its ownership-checked lifecycle to adopt the embedded gateway. Its existing bcrypt hash, CSRF secret, and Caddy storage remain in place, including the CA trusted by phones. Private state, history, lease settings, credentials, and the incomplete installation marker remain authoritative across failures and retries. Pueue and a separate Caddy executable are not required for development, installation, or acceptance tests. +Managed service migration, replacement and removal require a fully drained queue before service changes. Migration from Python also requires disabled old remote access. When old phone access blocks explicit setup, choose **Disable phone access and continue** to stop it through the bundled controller's ownership checks and resume installation. Credentials, configuration, and trusted certificates remain available; phone access can be enabled again in settings after setup. The CLI equivalent is the trailing `--disable-phone-access` option on `agent-heavy install --payload DIRECTORY` or `agent-heavy install --app-bundle ABSOLUTE_APP_PATH`. This recovery only disables phone access when it blocks Python migration. After ownership-checked shutdown, it imports completed tasks and the retained ID sequence into the new SQLite queue, then starts the independent Go daemon and resource controller. Later managed upgrades also require drained jobs before unregistering changed helpers; active work blocks changes. An enabled Go remote service is restarted through its ownership-checked lifecycle to adopt the embedded gateway. Its existing bcrypt hash, CSRF secret, and Caddy storage remain in place, including the CA trusted by phones. Private state, history, lease settings, credentials, and the incomplete installation marker remain authoritative across failures and retries. Pueue and a separate Caddy executable are not required for development, installation, or acceptance tests. The command queue uses the existing pure Go SQLite driver. A durable attempt is claimed before process creation, and detached workers publish their own outcomes. Never automatically retry an attempt with an uncertain outcome. An unclean daemon restart pauses automatic dispatch; verified process ownership and explicit operator recovery remain required. Queue RPC handlers must not acquire backend filesystem locks already held by the scheduler or submitting process. diff --git a/README.md b/README.md index 0b726ff..32537cd 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,13 @@ Screenshots use sample jobs. The Mac image uses the app's safe preview mode. ### Homebrew -On macOS 15 or later, install the app and CLI through the project's [Homebrew tap](https://github.com/Dunky13/homebrew-agent-job-queue): +On macOS 15 or later, obtain the installer and CLI links through the project's [Homebrew tap](https://github.com/Dunky13/homebrew-agent-job-queue): ```sh brew install --cask dunky13/agent-job-queue/agent-job-queue ``` -Homebrew installs the menu-bar app in your account's Applications folder and links `agent-heavy` and `agent-remote` into its bin directory. Open Agent Queue to complete its private queue setup; the command-line tools are ready once setup finishes. The app shows progress and lets you retry if setup fails. No Xcode or manual installer window is needed. +Open **Agent Job Queue Installer.app** in your account's Applications folder and choose **Install**. It completes queue setup and installs the menu app. Homebrew links `agent-heavy` and `agent-remote` into its bin directory; those links become ready after setup. Finish all queued and running jobs before installing or upgrading the runtime. If macOS requires background-service approval, review Agent Queue in System Settings, then finish installation. Opening the menu app checks readiness without installing or repairing files. No Xcode is needed. New releases produced by the notarized release pipeline use Developer ID signatures and Apple notarization. Their Homebrew cask verifies the download's SHA-256 and retains quarantine for macOS assessment. It does not bypass Gatekeeper. Older published builds remain ad hoc signed; their historical casks are preserved until a newer release replaces them. See [Apple signing and notarization](docs/apple-signing.md) for the release requirements and verification limits. @@ -43,24 +43,32 @@ The tap becomes installable after the first validated `main` installer release i brew upgrade --cask dunky13/agent-job-queue/agent-job-queue ``` -Each successfully published main installer triggers and verifies its Homebrew delivery. Scheduled reconciliation provides recovery. Maintainer setup, failure handling and trusted tap tooling synchronization are documented in [Verified Homebrew delivery](docs/homebrew-delivery.md). +After a Homebrew upgrade, open the updated installer and choose **Install** to upgrade Agent Queue. Homebrew's install-hook sandbox cannot access the private queue socket or manage native services, so Homebrew owns installer delivery while the explicit installer owns the runtime app. Each successfully published main installer triggers and verifies its Homebrew delivery. Scheduled reconciliation provides recovery. Maintainer setup, failure handling and trusted tap tooling synchronization are documented in [Verified Homebrew delivery](docs/homebrew-delivery.md). -Uninstalling the cask removes the menu app and Homebrew command links. It preserves the queue daemon, running jobs, private backend, data, credentials, and any enabled remote service. Disable remote access in settings or with `agent-remote disable` first if you want it stopped. +Homebrew uninstall removes its installer and command links. It leaves the installed Agent Queue app, its private command entry points and services in place. To remove a managed runtime, finish queued and running jobs, then run the following command before moving the app to Trash: -### Drag to Applications +```sh +agent-heavy install --app-bundle "$HOME/Applications/Agent Queue.app" --unregister-services +``` + +Use the app's actual absolute Applications path if it differs. Exit zero confirms service shutdown and retires generated command entry points. Queue history, configuration, credentials and certificate data are preserved. Quitting the menu app during ordinary use leaves the independent queue and enabled phone access running. + +### Installer app 1. Use macOS 15 or later on an Apple Silicon or Intel Mac. 2. Download the DMG from [Releases](https://github.com/Dunky13/agent-job-queue/releases), then open it. Successful main CI builds trigger distribution; a prerelease appears after notarization and both architecture checks pass. CI runs also retain the installer as an artifact for 30 days. -3. Drag **Agent Queue.app** onto **Applications**, then open it from Applications. You can also copy it to your own Applications folder. Opening it from the disk image shows instructions to move it first. -4. On first launch, the app sets up your private queue and login services. It displays progress and lets you retry if setup fails. Once setup finishes, the app appears in your menu bar. Add your local bin directory to your shell's `PATH` to use `agent-heavy` by name. +3. Open **Agent Job Queue Installer.app** and choose **Install**. It verifies the package and queue, installs the app, and registers its background services. Finish all queued and running jobs before installing over an existing queue. +4. Complete any background-service approval requested in System Settings, then finish installation. The app appears in your menu bar once setup succeeds. Add your local bin directory to your shell's `PATH` to use `agent-heavy` by name. -The ZIP remains available for the updater, Homebrew, and per-user installation. Extract it, open **Agent Job Queue Installer.app**, and choose **Install**. Older disk images also contain this installer app. +The ZIP uses the same installer. Older drag-to-Applications images remain available, but copying a new managed app alone does not complete queue setup. Opening an incomplete app shows installation guidance and leaves the incomplete-installation marker intact. -The app includes prebuilt universal executables and a Go queue daemon. The Go remote service embeds Caddy's HTTPS and authentication modules. Homebrew, Pueue, Python, uv, Xcode, and a Go toolchain are not required on the installed Mac. Backend setup runs in your user account without `sudo`; Finder may ask for permission to copy the app into the shared Applications folder. The payload is self-contained, but first-launch Gatekeeper assessment can require Apple connectivity. Offline first launch of the standalone Go executables has not been verified. +The app includes prebuilt universal executables and a Go queue daemon. The Go remote service embeds Caddy's HTTPS and authentication modules. Homebrew, Pueue, Python, uv, Xcode, and a Go toolchain are not required on the installed Mac. Backend setup runs in your user account without `sudo` and defaults to your account's Applications folder. Executable locations must be protected from replacement by other accounts; a group-writable shared Applications folder is refused without changing its permissions. The payload is self-contained, but first-launch Gatekeeper assessment can require Apple connectivity. Offline first launch of the standalone Go executables has not been verified. The notarized release pipeline signs the installer, menu app and executable payload with Developer ID, submits the release to Apple, and staples tickets to the app bundles and DMG. Historical releases and local development packages can remain ad hoc signed. Installer checksums identify the downloaded bytes; they do not establish notarization. -Upgrades of the embedded Go queue preserve queue configuration, job data, the running daemon, concurrency settings, and remote credentials. Quit the menu app before replacing it in Finder, then open the new copy to update its per-user backend. First-launch setup leaves that app bundle in place. The ZIP installer continues to replace the menu app atomically in your user Applications folder. +The executable helpers and service definitions remain inside the signed app bundle. Apple's Service Management framework registers the queue and resource controller, optional phone service, and app login preference. They are visible in System Settings under Login Items. The installer does not copy new executable helpers to a hidden support directory or create a login service through `open -a`. + +Installations and upgrades preserve configuration, history, concurrency settings and remote credentials. They require a drained queue before unregistering or replacing services, because macOS unregisters running helpers before their code can be replaced safely. Active work causes a refusal before service changes; let it finish and run the explicit installer afterward. Normal app startup never retries a failed installation automatically. Keep incomplete-installation evidence until a verified installation succeeds. If endpoint protection quarantines files, obtain IT review before retrying; Developer ID and notarization do not establish acceptance by another security product. For the first upgrade from Pueue, finish all running and waiting jobs first. If migrating from the Python backend, also disable remote access in the old app. The installer refuses an unsafe migration before replacing support files, then replaces the drained private daemon and resource controller. Completed job IDs, logs, learning history, operator settings, and remote credentials are retained. Previously installed Homebrew packages are left alone because other software may use them. @@ -80,7 +88,7 @@ Do not clear quarantine to work around a failed verification of a release advert ## Version and updates -Choose **About and updates** from the queue's gear menu to see the running app and installed controller versions. **Check for updates** finds published builds, including builds with the same version number. **Download and open installer** verifies the selected build and opens the existing installer; choose **Install** there to finish. Running and queued jobs remain with the independent daemon. +Choose **About and updates** from the queue's gear menu to see the running app and installed controller versions. **Check for updates** finds published builds, including builds with the same version number. **Download and open installer** verifies the selected build and opens the installer. Finish queued and running jobs, then choose **Install** there to replace and register the bundle-owned services. Legacy and local builds are labelled untracked and can explicitly choose a published installer. The updater does not install automatically. It preserves Gatekeeper policy and refuses tracked downgrades. CLI equivalents and verification details are in [Installed version and updates](docs/updates.md). diff --git a/docs/apple-signing.md b/docs/apple-signing.md index 3799453..38934dd 100644 --- a/docs/apple-signing.md +++ b/docs/apple-signing.md @@ -130,9 +130,12 @@ and never executes its signed target. Before publication, separate fresh Apple Silicon and Intel jobs verify final checksums, signatures, tickets and app-copy policy, exercise the hardened queue -and HTTPS gateway, and run the actual Homebrew installer with quarantine kept. -The Homebrew test owns a private daemon and user directory, substitutes only -its private launchctl fixture, and bounds installer descendants before cleanup. +and HTTPS gateway, and exercise actual Homebrew delivery with quarantine kept. +The runtime test then invokes the signed explicit installer outside Homebrew's +restricted hook sandbox. It owns private daemon processes and user directories, +substitutes native lifecycle and launchctl readback, and bounds descendants +before cleanup. These checks do not qualify real Service Management registration, +approval, subsequent login or reboot; those need an isolated login-session test. No Apple credential is passed to these acceptance jobs. Checkout precedes all artifact downloads so its workspace cleanup cannot erase the release inputs. @@ -335,8 +338,9 @@ Do not submit the same pending build repeatedly to try to accelerate processing. ## Distribution and acceptance -Both the native installer and new Homebrew casks directly execute the signed -Go controller. The cask preserves quarantine. Historical ad hoc casks remain +The native installer directly executes the signed Go controller. New Homebrew +casks deliver that explicit installer without executing setup in a hook. The +user opens it and chooses Install. The cask preserves quarantine. Historical ad hoc casks remain unchanged until the next numbered release; the trusted tap helper validates their original cask as bounded data. Merge the companion trusted tap update before the source release, as documented in [Homebrew delivery](homebrew-delivery.md). @@ -352,7 +356,9 @@ Use `codesign --verify --check-notarization` with an explicit `notarized` requirement for the standalone Go executables. An app-oriented `spctl` check does not establish the correct behavior for every non-app code type. The quarantined Homebrew test verifies both helpers' notarization before exercising -the actual signed installer entry. Apple documents these distinct checks in +the actual signed installer entry outside Homebrew's hook sandbox. Native +service operations remain simulated there; signing and packaged job execution +cannot certify live Service Management or endpoint-security acceptance. Apple documents these distinct checks in [Testing a Notarised Product](https://developer.apple.com/forums/thread/130560). Signing, notarization and Gatekeeper acceptance are distinct results. A normal diff --git a/docs/handoffs/bundle-owned-services.md b/docs/handoffs/bundle-owned-services.md new file mode 100644 index 0000000..6336132 --- /dev/null +++ b/docs/handoffs/bundle-owned-services.md @@ -0,0 +1,102 @@ +# Bundle-owned background services + +## Scope and accepted behavior + +The user authorized local implementation of explicit installation, read-only ordinary startup, and Apple's supported Service Management lifecycle after repeated SentinelOne quarantines on a different Mac. Source baseline: `ced25f439b10496f819a6237f5bd53942fbcb791`. This change does not authorize an affected-Mac repair, security exclusion, Apple submission, release or deployment. + +Original incident evidence and the build-58/build-94 comparison are recorded in the [SentinelOne investigation](sentinelone-detection-investigation.md). + +Requirements carried into implementation: + +- Installation and upgrades perform setup explicitly. Opening an incomplete app shows instructions without rewriting files or automatically retrying recovery. +- Runnable helpers and launch definitions stay inside the signed owning app. Login uses the owning app's Service Management registration, without an `open -a` LaunchAgent. +- The daemon remains independent of the menu UI. Migration, replacement and removal require a drained queue before service changes; active work is preserved through refusal. +- User approval and current phone-access intent remain authoritative. Missing, malformed or uncertain ownership never permits a fallback registration or process kill. +- Pending installation identities remain authoritative across failure and retry. Completed identities cannot certify an incomplete installation. +- Apple Silicon and Intel packaging, signatures, quarantine, retained archives and queue history remain supported. +- Local source/fixture validation is separate from live Service Management, signed release and SentinelOne qualification. + +## Layout and lifecycle + +New packages declare the boolean `AgentQueueServiceManagement` marker. `AgentQueue`, `agent-heavy` and `agent-remote` live in `Contents/MacOS`. The three queue, resource-controller and optional phone-service definitions live in `Contents/Library/LaunchAgents` and use bundle-relative `BundleProgram` paths. The app's support payload retains metadata and documents without duplicate helper binaries. The outer installer retains its own controller payload. + +The native `QueueServiceManagement` interface accepts fixed status/register/unregister operations for queue, resources, remote and login. It validates the owning bundle before constructing an adapter, reports approval states explicitly, propagates failures and waits for asynchronous unregister completion. The app handles these commands before starting AppKit. Ordinary app startup reads readiness and service status; installation/recovery requires an explicit installation workflow. + +The dedicated replacement interface politely quits only a running menu app with the exact canonical target URL, bundle identifier and current-user ownership. It excludes the native caller and other copies, waits for exit, and refuses changed identity, denied termination or timeout without force-killing. Explicit payload replacement and removal invoke it after drain and before changing bundle custody. In-place `--setup` does not terminate the app owning setup. The final one-shot app open happens after durable completion; registering the main app as a login item alone does not open its UI immediately. + +Apple's SDK states that changed helper code requires re-registration, unregister terminates a running helper, and the asynchronous completion callback is the safe point for re-registration. These requirements are the basis for the drain and completion gates, not a prediction about SentinelOne's detector. [Apple Service Management](https://developer.apple.com/documentation/servicemanagement/smappservice), [registration](https://developer.apple.com/documentation/servicemanagement/smappservice/register%28%29), [unregister completion](https://developer.apple.com/documentation/servicemanagement/smappservice/unregister(completionhandler:)). + +The Go installer performs bounded no-follow ownership reads, validates package identity/signatures, checks drain and service ownership, and rechecks under its installation/scheduling/remote locks. It retains the pending identity through app publication, native registration, readiness and remote adoption. `installation.json` records the canonical app location; malformed metadata cannot select the legacy path. Unmarked retained archives retain their existing behavior, but cannot overwrite an established managed installation. + +Readiness validates the actual public `status --json` response (`queue`, `jobs`, `scheduler` and timestamp). Drain uses the independent daemon's private snapshot (`tasks` and `groups`). Tests must preserve this distinction instead of returning a private snapshot for the public command. Native commands have a 35-second outer limit around the bridge's 30-second completion limit, preserving time for startup and a diagnostic result. + +Read-only native status is bounded separately at five seconds. Phone lifecycle has a composed two-minute stop/start budget, including native commands, status, process shutdown and readiness. Both the installer caller and native UI allow 125 seconds for that controller. Tests capture the actual subprocess context and preserve shorter caller cancellation. Payload installation and preflight select the recorded application location; renamed direct Applications bundles remain valid. Shared Applications is never chmodded, and a payload replacement that this account cannot complete is refused before service changes. Immutable root-owned bundles support explicit in-place setup only when their executable namespace is protected. + +The startup executor uses that five-second native status budget even when its other requests use the longer explicit setup budget. Only the exact status argument form is a discardable read; Stop waiting cancels and reaps its private controller without interrupting an accepted mutation. + +Shared-folder publication uses a separate bounded, no-follow descriptor writer. Production native copying preserves quarantine, ACLs, resource forks and extended attributes inside a private workspace, verifies the candidate, then moves it through checked directory descriptors. The generic private-state writer retains its stricter ancestor rules. Atomic shared-folder publication requires the home staging workspace and Applications to share a filesystem; a mismatch is refused during preflight before services stop. Actual system-folder writes remain a live qualification hold. + +Execution requires protected ancestors. A group-writable shared Applications directory cannot retain custody of an executable name, even when an open directory descriptor and a valid signature are available. Such publication targets are refused before quiescence, and the Go and native execution paths reject the same unsafe namespace. Read-only package inspection retains its bounded shared-folder exception. The installer defaults to the account's Applications directory; it never changes system directory permissions to make a shared target pass. Root-owned sticky temporary ancestors outside the bundle retain their normal owner protection. + +Publication pins the selected build before staging, verifies the candidate privately, then rechecks source, pending identity and installed target before activation. A private `publication-custody.json` receipt binds the app path and pending build to the exact candidate and optional backup device/inode, byte digest and build. An unrelated retained backup blocks even an identical reinstall before service changes. Recovery and cleanup require the same receipt and exact filesystem object; substituted entries and uncertain evidence remain intact. Failed staging removes only candidates and workspaces the installer still owns. Historical filename-only crash leftovers without a receipt are preserved for operator review rather than silently adopted. + +Completing an interrupted payload publication through explicit in-place setup follows the same retained-custody validation and cleanup. It retires the exact owned backup and receipt before clearing the pending marker, so a successful retry cannot strand a record that blocks the next upgrade. A retained backup that cannot be cleaned with this account's permissions causes refusal before service changes. Immutable protected in-place setup without retained publication custody requires no app-bundle writes. + +Phone access uses the same owning native interface. An ordinary enable is blocked while installation is pending or approval is revoked. The explicit installer migration reads the latest setting under the controller lock, and final installation publication holds that lock while verifying the current choice. It cannot re-register phone access from an earlier enabled snapshot. + +## Installation channels and removal + +The managed DMG presents the explicit installer. Historical self-installing drag images retain their verification path. Homebrew delivers the explicit installer and command links. The user opens the installer and chooses Install to install or upgrade the runtime. Homebrew upgrade or uninstall touches its delivery artifacts, leaving the independently installed runtime app and services in place. No install hook attempts native registration or queue IPC. + +This channel decision follows installed Homebrew source evidence: its macOS sandbox denies outbound Unix sockets, general Mach service lookup, Launch Services opens and Apple events. Declaring network access does not authorize the private queue socket. Running lifecycle commands there cannot establish drain or readiness, and weakening the sandbox is outside scope. Homebrew's predecessor removal also happens before successor installation. Historical casks can delete a legacy menu plist while its job remains loaded; migration recognizes only the exact canonical loaded executable and arguments, rechecks custody and unregisters that known job. Arbitrary orphaned jobs remain refused. + +Explicit `--prepare-replacement` and `--unregister-services` operations retain the same ownership and drain gates for callers outside that sandbox. Removal must return zero before deleting the runtime bundle. Data, history, configuration and credentials remain retained. Direct payload and app-bundle installation inspect generated CLI wrapper contents before any mutation, recheck under locks and recheck before publication. Custom scripts and symlinks are refused; exact historical Go and Python wrappers are supported. + +A failed preparation or native shutdown blocks removal/replacement. Quitting the menu UI during ordinary operation does not unregister services. An incomplete marker is cleared only by successful explicit completion, not by ordinary startup or an optimistic cleanup command. + +Replacement preparation refuses any unresolved publication receipt before service changes, because an external bundle move would invalidate the recorded candidate inode. Removal also refuses while that receipt retains a last-good backup; complete the exact pending setup first. A partial first installation with candidate-only custody can still unregister denied services: removal validates and retires its private receipt before clearing pending, leaving the app intact. The original denied-approval teardown regression remains unchanged and passes. + +The private packaged acceptance test exposed a shutdown race after the review rounds: the scheduler could wait on the installer's retained scheduling lock while its signal handler was only observed after the current tick. A real child-process regression reproduced the ignored termination. The scheduler now owns a cancellation context, and its coordination-lock waits and queue RPC connection observe cancellation. The installer keeps its lock; stopping the helper does not require a forced kill or a larger timeout. The regression verifies that the lifetime lock is released and the installer's lock remains held. A private stalled-RPC test verifies connection closure and already-cancelled refusal. + +## Validation and remaining qualification + +Local implementation and validation are complete. Both permitted review rounds have finished, and their findings are fixed and regression-tested. No commit, push, release, Apple submission, real service registration or affected-Mac repair was performed. Completed evidence: + +| Boundary | Evidence | Result | +| --- | --- | --- | +| Earlier complete repository pipeline | Queue job 2119, `scripts/validate.sh` | Passed source/lint/vet, reachable-vulnerability scan, all Go race suites, standalone tap tests, Linux tooling builds, browser suites, native tests and universal artifact checks. Later custody and namespace changes require the fresh final run below. | +| Publication regression | Queue jobs 2137 and 2138 | Reproduced acceptance of a changed candidate and overwrite of a newer target. The regression is retained. | +| Final custody implementation | Queue job 2145 | Focused managed race tests and backend vet passed, including the preserved identity regression and new backup/staging/recovery cases. | +| Executable namespace and native behavior | Queue job 2144 | App-services race tests and the full native suite passed. Both distributed architectures compiled; 236 service-management checks ran on Apple Silicon with simulated mutations. Private status cancellation reaped its actual child. | +| Fresh complete repository pipeline | Queue job 2146, `scripts/validate.sh`, exit 0 | Passed all source/lint/vet and Go race suites, standalone tap tests, Linux tooling builds, seven web tests, sixteen browser tests, the full native suite, universal ZIP/DMG verification and packaged runtime acceptance. Reachable Go vulnerabilities: zero. The scan also reported one imported-package and one required-module advisory not called by this code. Its backend race binary precedes the final retry fix; the native and artifact steps follow that fix. | +| In-place retry regression | Focused `TestManagedInPlaceRetry` before and after the round-1 fix | Reproduced stranded receipt and substituted-backup acceptance before the fix; all three retry regressions passed afterward, 2.990 seconds. | +| Current backend after retry fix | Queue job 2150, exit 0 | Repository lint/vet passed; the full current backend race suite passed in 219.407 seconds, including the new retry regressions. | +| Actual packaged explicit installer | Queue job 2146 | Actual controller and packaged helpers completed setup, private jobs, removal and owned process teardown under race, 68.64 seconds. Native lifecycle calls were injected. | +| Disk image | Queue job 2146 | Checksum and enclosed universal installer verification passed; read-only mount detached successfully. | +| Final lifecycle findings | Focused `TestManagedLifecycleDoesNotStrandPublicationCustody` | Reproduced all four preparation/removal cases before the fix. Afterward the lifecycle and retry regressions, including unchanged denied-approval teardown, passed in 6.787 seconds. | +| Lifecycle validation and preserved failure | Queue job 2151, exit 1 | Lint/vet, Linux tooling builds, universal build and full backend race tests passed, 215.968 seconds. Packaged payload acceptance failed because the scheduler required forced teardown; app-bundle acceptance passed. This failure is retained, not reported green. | +| Scheduler shutdown regression | Focused child-process and private RPC race tests | The installer-held-lock case failed before the fix. Afterward both tests passed, 2.584 seconds. The original fixture shutdown limits remain unchanged. | +| Shutdown and package validation | Queue job 2152, exit 1 | Lint/vet and full race suites passed: backend 209.383 seconds, remote 155.637 seconds, app-services 1.294 seconds. Linux and universal builds passed. The previously failing packaged payload teardown passed, as did packaged app-bundle and gateway acceptance. The outer-installer fixture then hit a safe busy-lock refusal before removal was accepted. This failure and its private witness are retained. | +| Final private removal acceptance | Queue job 2153, exit 0 | Repository lint/vet passed. Actual packaged explicit setup, private jobs, deliberately refused busy removal, successful removal, data retention and owned service teardown passed under race in 69.51 seconds. DMG checksum, enclosed universal installer verification and detach passed against the unchanged production artifacts. Native Service Management calls were injected. | + +The user limited final review to two rounds. In round 1, Standards and Spec independently found the same in-place retry issue described above and no other actionable finding within scope. Round 2 confirmed that issue closed in source; Standards found the preparation and removal issues, and Spec independently found the removal issue. Those findings and the later test-discovered scheduler shutdown race were reproduced and fixed locally, with no third review. The final fixes are regression-tested rather than independently re-reviewed; neither final review is reported CLEAN. Cross-provider review is unavailable under the operator's current reviewer policy and is not reported CLEAN. + +The final fixture deliberately holds the private scheduling lock for the first removal attempt. It requires the existing busy diagnostic, an absent pending marker, unchanged installation identity and command wrappers, and an intact native-call witness with no mutation. It then releases the lock and permits at most three attempts within the unchanged operation deadline. Any accepted-mutation failure remains fatal. This exercises the product's existing safe refusal and explicit retry contract rather than assuming a background scheduler can never own the lock. Production locking was not relaxed. + +The reviewed round-2 source manifest SHA-256 was `34b11c28ac36562cbdfc75a4bb0e896bd37c26cc3f5577d23d34f66881b6b659`; both reviewers verified all 62 entries before and after review. The final source manifest covers 66 changed source files, excluding Markdown, against the baseline above. The four additional files contain the scheduler cancellation fix and its regression. The artifact build used manifest `3d0527b820a374650557e8c327d3b2b48b37e25f9f392f63113e05764446dddd`. The subsequent fixture-only correction yields final manifest `345889db5fa744ba9fb1597cf392627d3f52f8987a4ea33270da2669ba7049ea`; production code and artifact bytes remain unchanged. Final development artifacts use ad hoc signatures: + +| Artifact | SHA-256 | +| --- | --- | +| `dist/Agent-Job-Queue-Installer-0.1.0-universal.zip` | `eee79486b2546540b48fb2a07f85ce50c8d58cef90e6f556ecf6dc087b310b6b` | +| `dist/Agent-Job-Queue-Installer-0.1.0-universal.dmg` | `a5a2031c3d2c7eb008119dfca88d27a0cd307a85970ea839e9fb2b83d54cf0c1` | + +Tests use private homes, injected service adapters and owned private daemon processes. A private HOME alone does not isolate the real macOS Service Management login domain. Live registration and approval tests require an explicitly isolated login session; they cannot be inferred from successful mock calls or signed-bundle inspection. + +The prior build-94 reference signatures and notarization checks documented in the investigation apply to that published artifact, not to this changed working tree. New local development artifacts are not Developer ID release qualification. No SentinelOne console record or protected retest is available here. The intended security-product result remains unverified until the identified candidate is tested under the employer's actual policy. + +Remaining acceptance boundaries: + +- A release candidate needs the existing Developer ID, Apple notarization, stapling and both-architecture acceptance pipeline. Local ad hoc artifacts are development evidence. +- Real registration, approval denial/revocation, login, helper termination and upgrade/removal need an isolated macOS login session. The private fixtures test the controller and real queue with simulated native lifecycle operations; they do not isolate or exercise the workstation's actual Service Management registry. +- Shared-system-folder installation and cross-platform metadata fidelity need platform acceptance. Protected synthetic directories and real private `ditto` quarantine/extended-attribute checks do not qualify writes to the actual system Applications folder. +- The employer's IT team must inspect the actual SentinelOne detection and test the identified signed candidate under its existing policy. Installation timing and native service packaging alone cannot establish that the original detection is resolved. No exclusion, scanner bypass or affected-machine retry is part of this implementation. diff --git a/docs/handoffs/sentinelone-detection-investigation.md b/docs/handoffs/sentinelone-detection-investigation.md new file mode 100644 index 0000000..ef39efd --- /dev/null +++ b/docs/handoffs/sentinelone-detection-investigation.md @@ -0,0 +1,184 @@ +# SentinelOne first-launch persistence investigation + +Investigated 2026-10-26; updated after the user supplied an incident summary and clarified that the affected Mac is a different machine from the analysis workstation. The summary identifies build 94 and repeated SentinelOne quarantine during installation and first launch. Its surviving helper hashes match the public build-94 menu-app helpers independently inspected here. This is a suspected false positive, not a confirmed vendor verdict: the authoritative detection record, exact rule, agent version and policy remain unavailable. No downloaded app or installer was executed and no endpoint settings were changed. + +## Updated finding: build 58 compared with build 94 + +The first-launch change explains why opening the new app repeats installation. The user subsequently identified the earliest quarantine as the separate installer's `--payload` path, prepared by the older updater. With later quarantines also occurring on the app's `--app-bundle` path, self-installation is not a necessary condition for the reported block. The hidden destination directory, temporary-file writer and three service definitions already existed in build 58. + +Compared fixed sources `f892944dca4159ff125d2ad841661e1dfd36af96` and `fe05988e07165afd8b23c9edda95a90ff0bf2ad8`: + +| Behavior | Build 58 | Build 94 | Interpretation | +| --- | --- | --- | --- | +| Ordinary app launch | Starts the menu UI and polling | Checks embedded setup and can run installation automatically | Material change: launching can now copy executable files and install services | +| Packaged Install button | Embedded controller runs `install --payload` | Same entry point remains | A separate explicit installation path still exists | +| Helper destination | Private `.local/share/agent-heavy/bin` | Same | Not newly introduced | +| `.install-*.tmp` write, chmod and rename | `installAtomic` | Identical function | Observed temporary executable writes use an existing mechanism | +| Login-service definitions | Daemon, scheduler and menu app | Identical `serviceDefinitions` function | `RunAtLoad` and restart policies were not introduced by this upgrade | +| Source reads and licenses | Older payload ownership checks | App-bundle-aware source validation and license traversal | Destination helper-copy sequence remains the same | +| Remote lifecycle, queue dispatch, Go dependencies | Existing implementations | No changes to `internal/remote/control.go`, `internal/backend/queue_daemon.go`, `go.mod` or `go.sum` in this comparison | No corresponding new remote-service or dependency mechanism found | + +The first-launch behavior was introduced by [commit 0b5ec48, drag-to-Applications installation](https://github.com/Dunky13/agent-job-queue/commit/0b5ec48c67bf4cb24cb758d48fb575f68871c889). It added the app's embedded setup payload and `AgentBootstrap.swift`, then inserted bootstrap before the normal menu UI starts. [Build-58 startup](https://github.com/Dunky13/agent-job-queue/blob/f892944dca4159ff125d2ad841661e1dfd36af96/native/AgentQueue.swift#L1668), [build-94 startup](https://github.com/Dunky13/agent-job-queue/blob/fe05988e07165afd8b23c9edda95a90ff0bf2ad8/native/AgentQueue.swift#L1721). + +Function bodies extracted from both commits have matching SHA-256 values: + +```text +installAtomic: +577bcb03e10f8bf72bdc9080685d07066ab849fd81fdf5b706f3ba974b3b4c1c +serviceDefinitions: +e8385c0891bf8f00acef37487620ca93e57bceb93955b928be79ef4b871ef54f +``` + +This is a source-level comparison, not an EDR bisection. Build 58 having installed successfully previously does not guarantee that reinstalling it under today's policy would succeed. Source, signatures/hashes, process ancestry, earlier events and endpoint policy are separate variables. Attribution of the earliest event to the separate installer comes from the user's affected-Mac log analysis, not independent access to those logs here. A policy/model change or a verdict involving the new binaries' identity/reputation remains plausible, not established. + +## Can setup happen only during installation? + +Yes. An explicit installation or upgrade operation can install the app and helpers, register approved services, verify readiness and commit the completed identity. Ordinary app startup can then inspect readiness and show the queue. If installation is missing or incomplete, it should show a clear repair instruction without automatically copying executable files or repeating setup. Any app-owned registration required by the selected native service API can run in an explicitly invoked setup mode; it need not run during ordinary launch. + +This changes the installation experience: a Finder drag alone would no longer complete backend setup. Users would need a clear separate Install/Setup operation, and the Homebrew flow would need a corresponding explicit completion path. Preserve the independent daemon and existing running-job behavior. + +The expected effect is fewer unexpected writes and no new repair attempt merely because someone opens the app. It does not resolve the observed SentinelOne block by itself: the separate installer already encountered quarantine while performing the same helper writes. Changing when that work happens does not remove the blocked operation. Native bundle-contained service packaging is a separate improvement, and neither change is evidence of scanner acceptance until tested under the employer's policy. + +Recommendation: separate installation from normal startup for reliability and user control, while investigating the SentinelOne verdict as its own acceptance issue. Obtain the authoritative threat record and pursue a suspected false-positive review. Do not present a blanket signer/directory exclusion or moving setup earlier as a proven fix. The user has now authorized implementation of installer-only setup and the supported native service lifecycle. The original incident remains separate from local implementation evidence. + +## Correlation with the supplied incident + +The user reports quarantined destination helpers, missing app launchers, an incomplete-install marker and surviving older daemon/controller processes. Treat those as evidence from the affected Mac, not as observations made on the analysis workstation. Private incident identifiers and raw endpoint logs are intentionally excluded from this portable report. + +The helper-copy events map to `support()`. It writes `agent-heavy` first, then `agent-remote`, and only afterward copies documents and licenses. In `execute()`, this entire stage precedes `serviceDefinitions()` and `launchctl bootstrap`. Therefore a failure returned during helper copying stops that attempt before its later service-registration code. The available timeline identifies executable writes as a concrete event boundary; it does not prove newly registered services caused that attempt's alert. Prior service state or preceding actions could still contribute to a behavioral decision. [Copy order](https://github.com/Dunky13/agent-job-queue/blob/fe05988e07165afd8b23c9edda95a90ff0bf2ad8/internal/backend/install.go#L1497), [stage order](https://github.com/Dunky13/agent-job-queue/blob/fe05988e07165afd8b23c9edda95a90ff0bf2ad8/internal/backend/install.go#L1876). + +The app-bundle installation path skips `publishApp()`. It is intended to leave the launching app bundle in place. Missing launchers or bundle metadata cannot be attributed to a deliberate self-delete in that path from the source inspected. The user's SentinelOne evidence supports security remediation as the working explanation; the console record is needed to attribute each removed file. [App-publication guard](https://github.com/Dunky13/agent-job-queue/blob/fe05988e07165afd8b23c9edda95a90ff0bf2ad8/internal/backend/install.go#L1938). + +The reported approximately two-second scan failures do not prove repeated installations every two seconds. Both queue UI polling and the resource-controller loop use a two-second cadence. Distinguishing the originating process requires the process tree and actual event records. [UI polling](https://github.com/Dunky13/agent-job-queue/blob/fe05988e07165afd8b23c9edda95a90ff0bf2ad8/native/AgentQueue.swift#L462), [controller loop](https://github.com/Dunky13/agent-job-queue/blob/fe05988e07165afd8b23c9edda95a90ff0bf2ad8/internal/backend/scheduler_controller.go#L686). + +## Recovery and containment implications + +1. Do not relaunch or reinstall on the affected Mac while the block is active. Preserve the incident records for IT. This investigation performs no repair and does not invoke the affected CLI. +2. Preserve `install-in-progress.json` pending approved recovery. It records incomplete installation and participates in version protections. The installer removes it after completing the successful path. Deleting it alone neither restores executable files nor proves a coherent installation. [Marker creation](https://github.com/Dunky13/agent-job-queue/blob/fe05988e07165afd8b23c9edda95a90ff0bf2ad8/internal/backend/install.go#L1876), [success cleanup](https://github.com/Dunky13/agent-job-queue/blob/fe05988e07165afd8b23c9edda95a90ff0bf2ad8/internal/backend/install.go#L1976). +3. Preserve current processes and job state while IT reviews the incident; do not restart the remaining services as a diagnostic. An alive daemon is not proof that new work can start: dispatch launches `execute-attempt` through the daemon's executable path, which can fail if that executable is missing. Existing worker processes and restartability require separate checks. [Dispatch](https://github.com/Dunky13/agent-job-queue/blob/fe05988e07165afd8b23c9edda95a90ff0bf2ad8/internal/backend/queue_daemon.go#L524). +4. Ask IT to review the actual threats and determine whether a vendor correction or narrowly supported exception is appropriate. The listed paths identify investigation targets; they are not a recommendation for broad recursive exclusions. A queue or ancestor-process exception must not silently exempt arbitrary queued children. +5. After approval, recover from a complete verified signed release bundle. Surviving helpers are comparison evidence, not a reason to execute around the quarantine. Preserve private queue data and recovery evidence, use an ownership-checked recovery path, verify installed bytes and services, and let successful installation clear its marker. Do not manually reconstruct a signed app from partial files. + +Required product work has two parts: a transparent native service lifecycle, and failure containment. The latter should stop automatic repair attempts after an interrupted installation until the user reviews the failure, and avoid publishing a mixture of helper generations. A per-file rename is atomic for one pathname; it does not make the multi-file upgrade transactional or prevent later EDR quarantine. No staging strategy can guarantee protection from security remediation, and retaining an unapproved copy would not be a valid recovery mechanism. + +The initial investigation did not run heavy validation. Later implementation uses the healthy analysis workstation queue; the affected machine's queue is reported broken; the investigation does not bypass the shared queue policy or use the still-running daemon as a test target. Future runtime testing belongs on an approved test machine with protection enabled, not by repeating the incident on the affected Mac. + +## Scope and concrete source findings + +The target is Agent Job Queue for macOS. The initial authorized endpoint was investigation and a resolution proposal, with documentation changes only. Subsequent user authorization covers local installer-only setup and bundle-owned service implementation. No real installation, service registration, security exception, sample submission, release, commit or PR has been performed; implementation validation is documented in [Bundle-owned services](bundle-owned-services.md). + +Source inspected: `ced25f439b10496f819a6237f5bd53942fbcb791`. The implementation files cited here are unchanged from published source `fe05988e07165afd8b23c9edda95a90ff0bf2ad8`, verified with a scoped Git comparison. + +1. First launch calls `bootstrap.start()`. When setup is needed and preflight passes, it automatically runs the embedded controller with `install --app-bundle APP_PATH`. A progress window appears, but there is no separate initial choice to enable background persistence. The ZIP installer's Install button is a different entry point. [Startup](https://github.com/Dunky13/agent-job-queue/blob/ced25f439b10496f819a6237f5bd53942fbcb791/native/AgentQueue.swift#L1721), [automatic setup](https://github.com/Dunky13/agent-job-queue/blob/ced25f439b10496f819a6237f5bd53942fbcb791/native/AgentBootstrap.swift#L443), [ZIP installer](https://github.com/Dunky13/agent-job-queue/blob/ced25f439b10496f819a6237f5bd53942fbcb791/native/Installer.swift#L16). +2. Setup copies `agent-heavy` and `agent-remote` into the user's private `.local/share/agent-heavy/bin` directory, outside the app bundle, and creates shell entry points in `.local/bin`. These are product runtime locations, not evidence of concealment by themselves. [Support installation](https://github.com/Dunky13/agent-job-queue/blob/ced25f439b10496f819a6237f5bd53942fbcb791/internal/backend/install.go#L1497). +3. Setup writes three definitions into the user's `Library/LaunchAgents` directory, with these fresh-install defaults. Existing daemon/controller choices can be retained on upgrade. [Definitions](https://github.com/Dunky13/agent-job-queue/blob/ced25f439b10496f819a6237f5bd53942fbcb791/internal/backend/install.go#L1571). + +| Service | Action | Fresh-install policy | +| --- | --- | --- | +| `io.went.agent-heavy` | Private `agent-heavy daemon` | `RunAtLoad=true`; restart after unsuccessful exit | +| `io.went.agent-resources` | Private `agent-heavy scheduler` | `RunAtLoad=true`; `KeepAlive=true` | +| `io.went.agent-queue` | `/usr/bin/open -a APP_PATH` | `RunAtLoad=true`; no keepalive | + +4. After copying support files, setup starts the daemon and controller using `launchctl bootstrap`, or restarts an existing controller with `launchctl kickstart -k`. An already-ready daemon is retained on ordinary upgrades. These are later installation stages; a helper-copy failure can prevent reaching them. Their presence alone does not identify which operation SentinelOne observed. [Activation](https://github.com/Dunky13/agent-job-queue/blob/ced25f439b10496f819a6237f5bd53942fbcb791/internal/backend/install.go#L1901). +5. Optional phone access can add `io.went.agent-remote` with login/restart behavior. It is not enabled by default. An already-enabled service may be migrated during an upgrade, so it is a conditional lead. [Remote definition](https://github.com/Dunky13/agent-job-queue/blob/ced25f439b10496f819a6237f5bd53942fbcb791/internal/remote/control.go#L675), [enable requirements](https://github.com/Dunky13/agent-job-queue/blob/ced25f439b10496f819a6237f5bd53942fbcb791/internal/remote/control.go#L730). + +The inspected baseline contained no `SMAppService` or `SMLoginItem` registration and no literal `persistence_deception` rule or executable. SentinelOne describes user LaunchAgents as a persistence mechanism its monitoring inspects. That supports investigating this sequence; it does not classify every LaunchAgent as malicious. [SentinelOne's macOS persistence explanation](https://www.sentinelone.com/blog/how-malware-persists-on-macos/). + +## Ranked explanations + +| Rank | Explanation | Distinguishing evidence | +| --- | --- | --- | +| 1 | The executable-write/process chain is classified as persistence behavior | The incident attributes helper writes and subsequent quarantine to installation. Obtain the detection timeline and compare the normal-install and first-launch entry points under the same policy on an approved test machine. | +| 2 | Build identity/reputation, prior events or changed endpoint policy contributes | Surviving helper hashes match build 94, weakening an ad hoc or altered-helper explanation for those files. Compare authoritative detections, policy and signer details across the original successful and failing events. | +| 3 | Upgrade-time phone-service migration or another child action contributed | Inspect actual ancestry and operations. Merely copying `agent-remote` is not evidence that phone access was enabled or its service started. | + +There is no employer SentinelOne console or incident export in this workspace, so no detector feedback loop was established. These are ranked explanations, not a reproduced root cause. + +## Published reference package: independently checked + +The latest public prerelease at initial inspection was [build-94-fe05988](https://github.com/Dunky13/agent-job-queue/releases/tag/build-94-fe05988), version `0.1.0`, bundle build `94`, published 2026-10-26. The user subsequently identified build 94 in the incident and supplied surviving helper hashes matching the reference menu-app copies below. The affected outer archive itself has not been compared. The reference ZIP directly downloaded here matches both its checksum file and release JSON manifest: + +```text +SHA-256 aaa89d93bb4249e909036d9ed0f7a0d7ac5536af907f6fad176bb6cb8f679a30 +``` + +| Check | Observed result | +| --- | --- | +| Metadata | Expected installer/menu bundle identifiers, version `0.1.0`, build `94` | +| Signatures | `codesign --verify --strict --all-architectures` passed for both apps and all four helper copies | +| Both architecture identities | Installer, menu app, and app-contained helpers identify Developer ID Application: Marc Went, Team ID `U9GXK89565`, timestamps and hardened runtime on `arm64` and `x86_64` | +| Stapled tickets | `xcrun stapler validate` passed for both apps | +| Gatekeeper | Both apps accepted by `spctl --assess --type execute`, source `Notarized Developer ID` | +| Hosted distribution | [Run 40325950843](https://github.com/Dunky13/agent-job-queue/actions/runs/40325950843) reports successful signing/notarization, Apple Silicon acceptance, Intel acceptance, publication and Homebrew delivery | +| SentinelOne execution | Not performed; neither downloaded app was launched | +| DMG verification | Not performed in this investigation | + +Reference helper hashes differ between independently signed app and installer copies. Match the incident's actual executable path: + +| Copy | SHA-256 | +| --- | --- | +| Menu-app `agent-heavy` | `9a87dc99e07f92c4fcf3af128498027e52a6eab1d8a5049fa0b317787c51263c` | +| Menu-app `agent-remote` | `2c351a3659a3c6ddb9ece3bd18bd0c2a833e3cd243749117ab9837728e6744e3` | +| Installer `agent-heavy` | `6e6a1c9e0ad721e29f9f811d3a1d3069b4aa2c3434abd8124584c7dc2510ecbc` | +| Installer `agent-remote` | `facaafcec106c6ab31cbfd2ee13dabb194ae7dfe71e2abc3034e14cec10ec85d` | + +Missing Developer ID signing is therefore not established as a problem with this reference release. These checks do not prove the employer's alert is false. + +## Product remediation options + +| Option | Concrete change | Tradeoff and verdict | +| --- | --- | --- | +| A. Native service lifecycle, recommended | Use `SMAppService.mainApp` for the menu-app login preference. Put queue/controller helpers and agent definitions in an owning signed app bundle and register with `SMAppService.agent`. Add explicit enablement, status, disable and removal controls. | Improves attribution, integrity and user control. Requires careful migration of independent services and live jobs. Durable direction, subject to a protected retest. | +| B. Retain registration, improve consent and removal | Add a clear first-run enable action, retained launch preferences and ownership-checked service removal while keeping the private daemon layout. | Smaller change with fewer migration risks, but direct LaunchAgent writes/activation remain and may still trigger the heuristic. | +| C. Explicit foreground-only mode | Offer a separately selected mode without automatic login-service installation. | Removes persistence installation from that mode but changes unattended and reboot/login behavior. A product choice, not a silent fix. | + +Option A is not a mechanical API substitution. The daemon must remain independent of the menu UI. Preserve state, process ownership checks and running jobs; establish how app replacement/deletion affects helper executable locations. The current Homebrew uninstall intentionally preserves the queue, jobs and enabled remote access. A new explicit complete-uninstall action should review active work and remove owned services after the chosen drain/cancel policy, while ordinary UI exit and upgrade retain independence. [Existing uninstall contract](https://github.com/Dunky13/agent-job-queue/blob/ced25f439b10496f819a6237f5bd53942fbcb791/packaging/homebrew/refresh.go#L759). + +Do not implement cosmetic filename/path changes to suppress a detector, remove quarantine, or disable protection. The objective is transparent supported lifecycle and vendor-reviewed acceptance. This describes the investigation recommendation; local implementation is now authorized and in progress. Scanner acceptance has not been established. + +## What the reported name establishes + +PaperCut's own support article, updated April 13, 2026, reports SentinelOne warnings named `persistence_deception` while installing its macOS client manually or through MDM. This is a documented precedent involving legitimate software. It does not establish that the current package is benign, define SentinelOne's internal rule, or identify the responsible executable. [PaperCut: PaperCut Hive and SentinelOne](https://www.papercut.com/kb/PaperCutPocketHive/papercut-hive-and-sentinelone/). + +No public SentinelOne rule definition for the exact string was found in this research. Treat the string as an alert value until the actual executable path, command line, hash, and incident details identify its meaning in this event. Inferring a specific triggering API solely from this name would exceed the evidence. + +## Why persistence behavior deserves inspection + +SentinelLabs explains that its behavioral detection considers a sequence of process actions. Its macOS.GMERA analysis specifically discusses writing a LaunchAgent, concealing its filename, and executing encoded scripts. Those combined behaviors motivate suspicion; the article does not say that every legitimate LaunchAgent is malicious or disclose the rule behind `persistence_deception`. [SentinelLabs: Detecting macOS.GMERA Malware Through Behavioral Inspection](https://www.sentinelone.com/labs/detecting-macos-gmera-malware-through-behavioral-inspection/). + +For this incident, correlate the first-launch process tree with persistence creation, helper locations, approval state and any upgrade-time migration. These remain investigation targets until matched to the actual event. + +## Supported macOS implementation path + +Apple documents `SMAppService` on macOS 13 and later for registering bundle-contained login items, LaunchAgents, and LaunchDaemons. Agent registration replaces manually installing agent plists into shared LaunchAgents directories. [Apple: SMAppService](https://developer.apple.com/documentation/servicemanagement/smappservice). + +Apple's installer sample places helper property lists inside a fully signed app bundle so modifications invalidate its signature and users can identify the owning app in System Settings. This is a concrete packaging and attribution improvement to evaluate. It is not a vendor promise that SentinelOne will accept the result. [Apple: Updating your app package installer to use the new Service Management API](https://developer.apple.com/documentation/servicemanagement/updating-your-app-package-installer-to-use-the-new-service-management-api). + +Registration remains subject to approval. A registered LaunchAgent is bootstrapped immediately and at subsequent logins. `requiresApproval` also represents revoked consent. A migration must preserve the independent queue daemon and job lifecycle while respecting disabled services, rather than tying daemon lifetime to the menu-bar app or silently restoring consent. [Apple: register()](https://developer.apple.com/documentation/servicemanagement/smappservice/register%28%29), [Apple: requiresApproval](https://developer.apple.com/documentation/servicemanagement/smappservice/status-swift.enum/requiresapproval). + +Apple's deployment guide provides enterprise background-task management by application identity, service label, or Developer Team ID. Such an MDM policy governs Apple's background-item approval; it does not approve the executable in SentinelOne. [Apple: Manage login items and background tasks on Mac](https://support.apple.com/guide/deployment/depdca572563/web). + +## Signing and notarization limits + +Apple describes notarization as automated malicious-content and signing checks that produce a ticket used by Gatekeeper. It is not App Review. The documentation does not establish approval by independent endpoint protection products. Therefore valid Developer ID signing, notarization, and stapling are useful artifact evidence, but they cannot prove that the reported SentinelOne event is a false positive or guarantee the absence of future detections. [Apple: Notarizing macOS software before distribution](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution). + +## Resolution through the employer's security team + +SentinelOne recommends examining behavior and incident indicators, submitting suspected false positives for review, and consulting support before Interoperability or Performance exclusions. It advises keeping exclusions narrow. Its customer portal supports tickets. [SentinelOne FAQ: How does SentinelOne handle false positives?](https://www.sentinelone.com/faq/), [SentinelOne customer support](https://www.sentinelone.com/global-services/get-support-now/). + +Recommended evidence for that investigation: exact release filename and download source; outer artifact and detected executable SHA-256 values; signing identity and Team ID; notarization evidence; detection time and install stage; process ancestry and command lines; written persistence items; related network events; agent version; policy; and the queued command, if any. The employer's team can retrieve private console details and submit an authorized vendor case. Do not publicly upload private packages or incident logs without authorization. + +PaperCut's article describes product-specific path exclusions. Do not copy that workaround to this queue app: a general command runner can execute many unrelated programs, so broad directory or descendant-process exclusions would create a larger protection gap. Prefer a vendor-reviewed correction, transparent supported packaging, and an employer-approved controlled retest with protection enabled. + +## Verification boundary + +Package signature, ticket and Gatekeeper assessments were performed only on the identified public reference ZIP. No SentinelOne-protected reproduction, vendor case, allow rule or MDM change was performed. A successful Gatekeeper check does not reproduce a behavioral first-launch alert. + +Before calling this resolved: + +1. Retrieve the original incident. After IT review, reproduce the same first-launch flow on an employer-approved test Mac with SentinelOne enabled under the same relevant policy. Do not retry on the affected production Mac. Record exact package/helper hashes and versions. If quarantined files are unavailable, retain the incident evidence rather than silently substituting another build. +2. Compare the unchanged baseline with a signed/notarized candidate, changing one relevant behavior at a time. Record alerts, process termination and quarantine. A quiet static scan is not this behavioral test. +3. Verify fresh first launch, existing-install upgrade, login/reboot, disabled services, menu-app quit and representative queued work. Exercise refused registration without repeated automatic re-registration. Check phone access only when explicitly enabled. +4. Verify Apple Silicon and Intel, supported installation channels, running-job survival across upgrade, and explicit complete removal. Keep signature/ticket/Gatekeeper results separate from SentinelOne results. + +The result that can be established is acceptance of identified bytes under an identified scanner version and policy, with vendor review as needed. No packaging change guarantees zero detections across virus scanners or future policies. diff --git a/docs/homebrew-delivery.md b/docs/homebrew-delivery.md index cbba0fc..6364f3d 100644 --- a/docs/homebrew-delivery.md +++ b/docs/homebrew-delivery.md @@ -16,15 +16,13 @@ When trusted templates change before a new app release, the tap retains a cask t ## App installation and upgrade recovery -The cask declares the embedded menu app as a Homebrew `app` artifact, targeting the current account's Applications directory, and links the stable command wrappers. It does not run setup or open the app from an installation hook. Open Agent Queue after installation to finish setup; command-line tools become ready after setup completes. The app's existing first-launch flow runs its signed embedded controller with `install --app-bundle` and presents a Retry action if setup fails. Setup reads the installed app's payload rather than the shared Caskroom path. Apple Silicon, Intel, and custom Homebrew prefixes follow the same path without changing Homebrew directory permissions. +The cask delivers `Agent Job Queue Installer.app` into the account's Applications folder and links the packaged command entry points. The user opens that installer and chooses Install. All queue setup, ownership checks, drain, app replacement, Service Management approval and completion happen in that explicit operation. Ordinary menu-app startup performs readiness reads only. -Payload ownership, symlink rejection, private-state permissions, quarantine, signatures, and process ownership checks remain enforced. Homebrew owns app movement and backup; the controller owns private backend setup. Stable command wrappers continue to point to the private backend. +Homebrew's installed macOS install-hook sandbox denies outbound Unix sockets, general Mach service lookup, Launch Services opens and Apple events. Declaring network access and writable product directories cannot make queue drain, readiness or native registration work there. The cask contains no executable installation or removal hook and does not weaken that sandbox. -For upgrades between these managed-app casks, Homebrew can restore the preceding app when installing later artifacts fails. Backend setup happens after the cask installation, so a first-launch failure keeps the newer app available for Retry. Preserve incomplete installation markers and retry the intended newer release after resolving its reported error. Uninstall removes the managed app and its login entry without elevation while retaining the independent queue and private state. +Homebrew upgrade replaces the delivered installer. Open it and choose Install to upgrade the runtime after queued and running work finishes. Homebrew uninstall removes its installer artifact and links, leaving the separately installed runtime app, services and private entry points intact. For full runtime removal, use the explicit `--unregister-services` command documented in the README, then remove the app only after success. Private history, configuration and credentials remain retained. -Homebrew's modern install-hook sandbox blocks private Unix-socket access and app launching. Even a signed setup helper with declared network and product-directory access cannot complete daemon readiness there. The cask therefore uses normal app installation and the app's own setup flow instead of weakening that sandbox. - -The first upgrade from an older script-installer cask still executes that installed cask's deletion rules. Its rollback metadata does not describe an app artifact, so the new cask cannot guarantee restoration of the old app on that transition. Do not report this legacy transition as covered by managed-app rollback. Existing releases and casks remain immutable; merge the companion tap tooling before the source repair and publish a new validated numbered release. +The first transition from a historical cask still executes that predecessor's own removal rules. A historical app artifact may be removed, but its daemon executable lives separately and active queue work must remain untouched. The new installer independently applies drain and ownership checks before migration. It can retire a loaded legacy menu job whose deleted definition was removed by the historical cask only when its executable and complete arguments match the canonical old command. Existing releases and casks remain immutable; merge the companion tap tooling before the source repair and publish a new validated numbered release. ## One-time GitHub App setup @@ -61,7 +59,7 @@ brew update brew upgrade --cask dunky13/agent-job-queue/agent-job-queue ``` -Publishing a release or updating the tap does not install the app on the operator's Mac. Version visibility requires the new app to be installed and running; open its gear menu and choose **About and updates**. +Publishing a release or updating the tap does not install the runtime on the operator's Mac. After upgrading the cask, open the delivered installer and choose Install. Version visibility requires the new app to be installed and running; open its gear menu and choose **About and updates**. ## Validation diff --git a/docs/updates.md b/docs/updates.md index 0783749..8671234 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -2,7 +2,7 @@ ## Operator flow -Open the queue's gear menu and choose **About and updates**. The window shows the running app's bundle version immediately and reads the installed controller version locally. Opening the window does not contact the network. Choose **Check for updates** to query the public project releases. Choose **Download and open installer** for an available update, then choose **Install** in the existing installer to complete the change. The installer restarts the menu app while preserving the independent queue daemon, queued and running jobs, settings and credentials. +Open the queue's gear menu and choose **About and updates**. The window shows the running app's bundle version immediately and reads the installed controller version locally. Opening the window does not contact the network. Choose **Check for updates** to query the public project releases. Choose **Download and open installer** for an available update, then choose **Install** in the existing installer to complete the change. Finish queued and running jobs before choosing Install. Managed upgrades verify the drained queue before unregistering and replacing bundle-owned services; settings, history and credentials are preserved. An installation without a published build identity is explicitly marked untracked. Its version string is displayed as recorded, including legacy bundle versions. **Download published installer** offers an explicit replacement with a published build rather than claiming that unknown build histories are comparable. Known newer published installations cannot be downgraded, including by an installer opened before another update completed. @@ -24,7 +24,9 @@ The existing release manifest contains repository, semantic version, monotonical Packaging embeds matching build-info.json records in both app bundles and the runtime payload before signing. Published main-branch CI receives its build number and commit from the CI environment. Local and pull-request packages remain untracked. For an intentionally tracked test package, supply BUILD_NUMBER and SOURCE_COMMIT together; this does not publish it. A checkout name or current branch alone does not establish a published build. -The installer validates source and app metadata before replacing support files. Under the existing installation lock, it rejects older tracked builds and conflicting equal-build identities across completed, app and pending-installation identities. Identical tracked builds can be reinstalled. A durable incomplete-installation marker precedes the first support-file replacement and remains after failure or interruption. It is cleared only after app replacement, resource-controller reload and the completed identity commit succeed. Reloading this separate controller applies updated prediction logic while the queue daemon keeps active jobs running. Runtime refuses to certify an incomplete installation and otherwise compares the completed marker, bundled identity and bundle version. Retry the same or a newer installer to complete an interrupted update. +The installer validates source and app metadata before replacing support files. Under the existing installation lock, it rejects older tracked builds and conflicting equal-build identities across completed, app and pending-installation identities. Identical tracked builds can be reinstalled. A durable incomplete-installation marker precedes the first support-file replacement and remains after failure or interruption. It is cleared only after bundle publication, native service registration and readiness, remote adoption and the completed identity commit succeed. Managed replacement requires a drained queue because unregistering a helper terminates it. Opening the app only reads installation and authorization state; it does not retry setup. Runtime refuses to certify an incomplete installation and otherwise compares the completed marker, bundled identity and bundle version. Complete a managed installation using its exact pending build before selecting another candidate. Unmarked legacy installers retain their existing version-ordering rules. + +Managed publication records exact candidate and backup custody in private installation state. A changed candidate, newer target, substituted backup or retained backup without an owned receipt blocks publication or recovery and preserves the evidence. Do not delete these records to force an update. Installation also refuses executable locations with group- or world-writable ancestors before service changes; the default target is the account's Applications folder. ## Download and installation boundary @@ -36,7 +38,7 @@ One preparation runs at a time. Private staging retains at most three opened ins The public GitHub release is the distribution trust boundary. New main releases produced by the notarized pipeline use Developer ID signatures and Apple notarization, with stapled tickets on both app bundles and the DMG. Historical releases and local development packages can remain ad hoc signed. The updater checks signature integrity and release identity; those checks alone do not establish Apple notarization or Gatekeeper acceptance. It does not disable Gatekeeper, remove quarantine, or add a background installation service. The self-contained payload needs no Go toolchain, but offline first launch of its standalone Go executables has not been verified. Older archives without embedded build metadata remain available through their release pages; the updater refuses to present those archives as verified new-format installations. -Homebrew's generated manifest remains compatible. The cask for new notarized releases retains quarantine. The trusted refresh helper preserves a historical cask for an unchanged published build and applies the new template only when a newer release is available; it never executes historical released Ruby. Direct installation through the app updater uses the vendor installer and does not rewrite Homebrew's receipt. Homebrew-managed installations can continue using their existing cask upgrade flow. When synchronizing the dedicated tap's helper scripts, copy the standalone refresh.go, its regression tests, and the reviewed workflow as documented in the tap seed instructions. The first upgrade from Pueue requires a drained queue; migration from Python also requires disabled old remote access. Subsequent embedded Go queue upgrades preserve active daemon jobs and restart enabled remote access with the embedded gateway, retaining credentials and the trusted CA. +Homebrew's generated manifest remains compatible. The cask for new notarized releases retains quarantine. The trusted refresh helper preserves a historical cask for an unchanged published build and applies the new template only when a newer release is available; it never executes historical released Ruby. Direct installation through the app updater uses the vendor installer and does not rewrite Homebrew's receipt. New casks deliver the explicit installer; after a cask upgrade, open it and choose Install to update the runtime. Homebrew's hook sandbox cannot manage the queue's private IPC or native services. When synchronizing the dedicated tap's helper scripts, copy the standalone refresh.go, its regression tests, and the reviewed workflow as documented in the tap seed instructions. The first upgrade from Pueue requires a drained queue; migration from Python also requires disabled old remote access. Subsequent managed upgrades also wait for a drained queue and retain remote credentials and the trusted CA. The remote controller reads the latest enabled choice under its own lock; final installation readback cannot restore an earlier enabled choice after a concurrent disable. ## Validation contract diff --git a/docs/upgrade-recovery.md b/docs/upgrade-recovery.md index 7c31ef4..c1fdb29 100644 --- a/docs/upgrade-recovery.md +++ b/docs/upgrade-recovery.md @@ -1,19 +1,21 @@ # Guided installation and upgrade recovery -First-launch setup checks the installation before changing it. When requirements need attention, the setup window lists them together with the affected jobs, service, or file. A clean preflight permits an installation attempt; the installer repeats its authoritative checks before publication, and the app verifies the installed state before opening the queue. +Normal app launch checks installation and background-service authorization without installing or repairing files. Use the installer, or explicitly invoke the app with `--setup`, to enter the installation workflow. Explicit setup checks the installation before changing it. When requirements need attention, the setup window lists them together with the affected jobs, service, or file. A clean preflight permits an installation attempt; the installer repeats its authoritative checks before publication, and the app verifies the installed state before opening the queue. ## Available choices - **Wait and continue** rechecks transient blockers for a bounded period. **Stop waiting** cancels the read-only check without stopping queue jobs or an accepted repair. Paused and stashed legacy jobs still need attention in the existing queue. -- **Review jobs** shows the unfinished legacy jobs. Cancelling a selected job requires confirmation because running work may be stopped. Normal Go upgrades retain active jobs and do not require this legacy drain. +- **Review jobs** shows the unfinished legacy jobs. Cancelling a selected job requires confirmation because running work may be stopped. Managed Go upgrades also require a drained queue before changing service registration. Active work blocks those changes. - **Run verified cleanup** uses the established ownership-checked cleanup path for a selected completed job. Setup does not remove receipts, leases, or lock files merely to bypass a blocker. - **Start verified service** starts a confirmed stopped, canonical installed Go service. An already loaded service or uncertain job attempt is not restarted by recovery; its diagnostics remain available for inspection. - **Disable phone access and continue** preserves credentials, configuration, and trusted certificates. It covers the Python migration blocker and a failed enabled remote migration during a Go upgrade. Access can be enabled again in settings after setup. - **Review configuration** opens the affected file and explains the proposed callback replacement. **Back up and replace callback** requires a separate confirmation, preserves the complete original privately, and checks that the configuration is still the version reviewed. The custom callback's behavior will no longer run after replacement. Arbitrary custom service definitions and missing configuration remain guided repairs rather than guessed defaults. -- **Show affected file**, **Open Applications**, and **Download current release** provide a path forward for unsafe private paths, misplaced apps, damaged packages, or a downgrade attempt. Use Finder to copy and reopen a correctly signed app; setup does not silently replace another app, relax signature checks, or take ownership of unknown files. +- **Show affected file**, **Open Applications**, and **Download current release** provide a path forward for unsafe private paths, misplaced apps, damaged packages, or a downgrade attempt. Use a complete signed installer to finish setup; a Finder copy alone does not register its services. Setup does not relax signature checks or take ownership of unknown files. Recovery failures retain the original diagnostic and refresh the preflight choices. A successful recovery is followed by a new preflight before installation continues. None of these choices authorizes an unidentified process to be killed, a shared lock to be removed, or a custom configuration to be silently overwritten. +If an interrupted publication retains an owned backup and receipt, finish the exact pending setup before preparing an external replacement or removing the runtime. Those operations refuse before service changes while the backup is needed for recovery. Do not delete the receipt or pending marker to bypass the refusal. A partial first installation without a backup can still unregister denied services; it retires candidate-only custody while preserving the app and queue data. + ## Controller contract The bundled `agent-heavy` controller supports the existing installation targets with a read-only preflight mode: @@ -37,7 +39,7 @@ agent-heavy install --app-bundle ABSOLUTE_APP_PATH --recover reset-callback --ex Each operation rechecks current eligibility and ownership. Callback replacement requires the revision supplied by the reviewed report. Recovery returns a fresh report and does not itself finish installation. Navigation and waiting are native UI operations, not arbitrary controller command execution. -The native transport treats only the exact read-only preflight command as cancellable. Installation and recovery retain the existing accepted-mutation behavior. Unsupported schemas, unknown action IDs, malformed job IDs, and invalid paths are rejected before execution. +The native transport treats exact read-only preflight and native service-status commands as cancellable. Installation and recovery retain the existing accepted-mutation behavior. Unsupported schemas, unknown action IDs, malformed job IDs, and invalid paths are rejected before execution. Executable locations with group- or world-writable ancestors are refused; setup does not change shared Applications permissions to bypass that refusal. ## Verification diff --git a/internal/appservices/appservices.go b/internal/appservices/appservices.go new file mode 100644 index 0000000..835fc3e --- /dev/null +++ b/internal/appservices/appservices.go @@ -0,0 +1,286 @@ +// Package appservices describes the installed, bundle-owned service boundary. +// It never registers services or changes files. +package appservices + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "golang.org/x/sys/unix" + "howett.net/plist" +) + +type Installation struct { + SchemaVersion int `json:"schemaVersion"` + App string `json:"app"` + ServiceManagement bool `json:"serviceManagement"` +} + +type Status struct { + SchemaVersion int `json:"schemaVersion"` + Service string `json:"service"` + Status string `json:"status"` +} + +// ReadFile performs a bounded no-follow read. Bundle files may be root-owned; +// private metadata must belong to the current user, including its ancestors. +func ReadFile(boundary, filename string, limit int64, bundle bool) ([]byte, error) { + return readFile(boundary, filename, limit, bundle, false) +} + +func readFile(boundary, filename string, limit int64, bundle, executable bool) ([]byte, error) { + boundary, filename = systemPath(boundary), systemPath(filename) + if limit < 0 || limit > 512*1024*1024 { + return nil, errors.New("invalid service metadata size limit") + } + if !filepath.IsAbs(boundary) || filepath.Clean(boundary) != boundary || !strings.HasPrefix(filename, boundary+"/") || filepath.Clean(filename) != filename { + return nil, errors.New("invalid service metadata boundary") + } + fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + current := "" + for _, part := range strings.Split(strings.TrimPrefix(filepath.Dir(filename), "/"), "/") { + current += "/" + part + child, openErr := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + unix.Close(fd) + if openErr != nil { + return nil, openErr + } + fd = child + var st unix.Stat_t + if err = unix.Fstat(fd, &st); err != nil { + unix.Close(fd) + return nil, err + } + if !serviceParentSafe(current, boundary, bundle, executable, &st) { + unix.Close(fd) + if executable { + return nil, errors.New("executable location permits replacement by another account; use the explicit installer in a protected folder and install in your account's Applications directory") + } + return nil, errors.New("unsafe service metadata parent") + } + } + defer unix.Close(fd) + fileFD, err := unix.Openat(fd, filepath.Base(filename), unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + f := os.NewFile(uintptr(fileFD), filename) + defer f.Close() + var before, after unix.Stat_t + if err := unix.Fstat(fileFD, &before); err != nil { + return nil, err + } + if uint32(before.Mode)&unix.S_IFMT != unix.S_IFREG || before.Nlink != 1 || uint32(before.Mode)&0022 != 0 || before.Uid != uint32(os.Getuid()) && !(bundle && before.Uid == 0) { + return nil, errors.New("unsafe service metadata file") + } + if before.Size > limit { + return nil, errors.New("service metadata exceeds size limit") + } + if executable { + if uint32(before.Mode)&0111 == 0 { + return nil, errors.New("managed helper is not executable") + } + return nil, nil + } + raw, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return nil, err + } + if int64(len(raw)) > limit { + return nil, errors.New("service metadata exceeds size limit") + } + if err := unix.Fstat(fileFD, &after); err != nil { + return nil, err + } + if before.Dev != after.Dev || before.Ino != after.Ino || before.Size != after.Size || before.Mode != after.Mode || before.Uid != after.Uid || before.Nlink != after.Nlink || before.Mtim != after.Mtim || before.Ctim != after.Ctim { + return nil, errors.New("service metadata changed during inspection") + } + return raw, nil +} + +func serviceParentSafe(current, boundary string, bundle, executable bool, st *unix.Stat_t) bool { + inside := current == boundary || strings.HasPrefix(current, boundary+"/") + owner := st.Uid == uint32(os.Getuid()) || st.Uid == 0 && (!inside || bundle) + // Shared Applications remains readable as package data. Executable paths + // cannot inherit that exception because their names must retain custody. + shared := !inside && st.Uid == 0 && (uint32(st.Mode)&unix.S_ISVTX != 0 || !executable && current == "/Applications" && uint32(st.Mode)&0002 == 0) + return owner && (uint32(st.Mode)&0022 == 0 || shared) +} + +// Resolve only macOS's fixed, root-owned aliases above a private boundary. +// Symlinks within homes, installation roots and bundles are always rejected. +func systemPath(path string) string { + for _, alias := range []string{"/var", "/tmp"} { + if path != alias && !strings.HasPrefix(path, alias+"/") { + continue + } + var st unix.Stat_t + if err := unix.Lstat(alias, &st); err != nil || uint32(st.Mode)&unix.S_IFMT != unix.S_IFLNK || st.Uid != 0 { + continue + } + target, err := os.Readlink(alias) + if err == nil && (target == "private"+alias || target == "/private"+alias) { + return "/private" + path + } + } + return path +} + +// decode rejects duplicate keys as well as unknown fields and trailing values. +func decode(raw []byte, value any) error { + if len(raw) > 16384 { + return errors.New("service JSON exceeds size limit") + } + d := json.NewDecoder(bytes.NewReader(raw)) + first, err := d.Token() + if err != nil || first != json.Delim('{') { + return errors.New("invalid service JSON object") + } + seen := map[string]bool{} + for d.More() { + key, err := d.Token() + if err != nil { + return err + } + name, ok := key.(string) + if !ok || seen[name] { + return errors.New("duplicate service JSON field") + } + seen[name] = true + var field json.RawMessage + if err := d.Decode(&field); err != nil { + return err + } + } + if _, err := d.Token(); err != nil { + return err + } + if _, err := d.Token(); err != io.EOF { + return errors.New("trailing service JSON") + } + d = json.NewDecoder(bytes.NewReader(raw)) + d.DisallowUnknownFields() + return d.Decode(value) +} + +func validAppPath(home, app string) bool { + return filepath.IsAbs(app) && filepath.Clean(app) == app && strings.HasSuffix(filepath.Base(app), ".app") && (filepath.Dir(app) == "/Applications" || filepath.Dir(app) == filepath.Join(home, "Applications")) +} + +// Load returns nil only when installation.json is absent. Malformed or unsafe +// metadata never falls back to the legacy service layout. +func Load(home string) (*Installation, error) { + root := filepath.Join(home, ".local/share/agent-heavy") + raw, err := ReadFile(home, filepath.Join(root, "installation.json"), 16384, false) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var value Installation + if err := decode(raw, &value); err != nil { + return nil, err + } + if value.SchemaVersion != 1 || !value.ServiceManagement || !validAppPath(home, value.App) { + return nil, errors.New("invalid managed installation metadata") + } + return &value, nil +} + +// BundleForExecutable accepts only the exact canonical helper spellings. +func BundleForExecutable(executable string) (string, bool) { + if !filepath.IsAbs(executable) || filepath.Clean(executable) != executable || !slices.Contains([]string{"agent-heavy", "agent-remote"}, filepath.Base(executable)) { + return "", false + } + app := filepath.Dir(filepath.Dir(filepath.Dir(executable))) + return app, strings.HasSuffix(filepath.Base(app), ".app") && executable == filepath.Join(app, "Contents/MacOS", filepath.Base(executable)) +} + +// ValidateApp allows immutable reads of a Finder-installed root-owned bundle. +// Cryptographic verification is performed by the installer before publication +// and by the owning native executable before lifecycle operations. +func ValidateApp(home, app string) error { + if !validAppPath(home, app) { + return errors.New("managed app must be in Applications") + } + return ValidateBundle(app) +} + +// ValidateBundle also supports an installer staging bundle outside Applications. +func ValidateBundle(app string) error { + raw, err := ReadFile(app, filepath.Join(app, "Contents/Info.plist"), 16384, true) + if err != nil { + return err + } + var info map[string]any + if _, err := plist.Unmarshal(raw, &info); err != nil { + return err + } + if info["CFBundleIdentifier"] != "io.went.agent-queue" || info["AgentQueueServiceManagement"] != true { + return errors.New("missing managed app packaging marker") + } + for _, name := range []string{"AgentQueue", "agent-heavy", "agent-remote"} { + filename := filepath.Join(app, "Contents/MacOS", name) + if _, err := readFile(app, filename, 128*1024*1024, true, true); err != nil { + return err + } + } + if _, err := ReadFile(app, filepath.Join(app, "Contents/Resources/payload/source/build-info.json"), 16384, true); err != nil { + return err + } + for service, helper := range map[string]string{"heavy": "agent-heavy", "resources": "agent-heavy", "remote": "agent-remote"} { + raw, err := ReadFile(app, filepath.Join(app, "Contents/Library/LaunchAgents/io.went.agent-"+service+".plist"), 16384, true) + if err != nil { + return err + } + var value map[string]any + if _, err := plist.Unmarshal(raw, &value); err != nil { + return err + } + if value["Label"] != "io.went.agent-"+service || value["BundleProgram"] != "Contents/MacOS/"+helper { + return errors.New("invalid managed service definition") + } + if _, exists := value["Program"]; exists { + return errors.New("managed service overrides BundleProgram") + } + command := map[string]string{"heavy": "daemon", "resources": "scheduler", "remote": "serve"}[service] + args, ok := value["ProgramArguments"].([]any) + if !ok || len(args) != 2 || args[1] != command || args[0] != helper && args[0] != "Contents/MacOS/"+helper { + return errors.New("managed service has unexpected helper arguments") + } + } + // The support payload must never carry a second executable installation. + if _, err := os.Lstat(filepath.Join(app, "Contents/Resources/payload/source/bin")); !errors.Is(err, os.ErrNotExist) { + return errors.New("managed app embeds private backend binaries") + } + return nil +} + +func Arguments(app, action, service string) ([]string, error) { + if !filepath.IsAbs(app) || filepath.Clean(app) != app || !strings.HasSuffix(filepath.Base(app), ".app") || !slices.Contains([]string{"status", "register", "unregister"}, action) || !slices.Contains([]string{"queue", "resources", "remote", "login"}, service) { + return nil, errors.New("invalid service management command") + } + return []string{filepath.Join(app, "Contents/MacOS/AgentQueue"), "--service-management", action, service}, nil +} + +func DecodeStatus(raw []byte, expectedService string) (Status, error) { + var value Status + if err := decode(raw, &value); err != nil { + return Status{}, err + } + if !slices.Contains([]string{"queue", "resources", "remote", "login"}, expectedService) || value.SchemaVersion != 1 || value.Service != expectedService || !slices.Contains([]string{"notRegistered", "enabled", "requiresApproval", "notFound"}, value.Status) { + return Status{}, fmt.Errorf("invalid native service status for %s", expectedService) + } + return value, nil +} diff --git a/internal/appservices/appservices_test.go b/internal/appservices/appservices_test.go new file mode 100644 index 0000000..4f261ea --- /dev/null +++ b/internal/appservices/appservices_test.go @@ -0,0 +1,194 @@ +package appservices + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" +) + +func TestExecutableNamespaceDoesNotInheritSharedReadException(t *testing.T) { + boundary := "/Applications/Agent Queue.app" + for _, mode := range []os.FileMode{0755, 0775, 0777} { + path := t.TempDir() + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } + var st unix.Stat_t + if err := unix.Stat(path, &st); err != nil { + t.Fatal(err) + } + st.Uid = 0 + if got := serviceParentSafe("/Applications", boundary, true, true, &st); got != (mode == 0755) { + t.Fatal("executable inherited shared directory permissions", mode, got) + } + if got := serviceParentSafe("/Applications", boundary, true, false, &st); got != (mode != 0777) { + t.Fatal("read-only package inspection lost its bounded exception", mode, got) + } + } + var temporary unix.Stat_t + temporary.Uid = 0 + temporary.Mode = unix.S_IFDIR | unix.S_ISVTX | 0777 + if !serviceParentSafe("/private/tmp", "/private/tmp/private-app", true, true, &temporary) || serviceParentSafe(boundary, boundary, true, true, &temporary) { + t.Fatal("sticky temporary exception escaped outside-ancestor scope") + } +} + +func TestDecodeStatusRejectsAmbiguousNativeResponses(t *testing.T) { + for _, raw := range []string{ + `{"schemaVersion":1,"service":"queue","status":"enabled"}`, + `{"schemaVersion":1,"service":"queue","status":"notRegistered"}`, + `{"schemaVersion":1,"service":"queue","status":"requiresApproval"}`, + `{"schemaVersion":1,"service":"queue","status":"notFound"}`, + } { + if value, err := DecodeStatus([]byte(raw), "queue"); err != nil || value.Status == "" { + t.Fatalf("valid response rejected %s: %v", raw, err) + } + } + for _, raw := range []string{ + `{"schemaVersion":2,"service":"queue","status":"enabled"}`, + `{"schemaVersion":1,"service":"remote","status":"enabled"}`, + `{"schemaVersion":1,"service":"queue","status":"unknown"}`, + `{"schemaVersion":1,"service":"queue","status":"enabled","extra":true}`, + `{"schemaVersion":1,"service":"queue","status":"enabled","status":"notRegistered"}`, + `{"schemaVersion":1,"service":"queue","status":"enabled"} {}`, + `{"schemaVersion":"1","service":"queue","status":"enabled"}`, + `{"schemaVersion":1,"service":"queue","status":null}`, + `null`, `[]`, ``, + } { + if _, err := DecodeStatus([]byte(raw), "queue"); err == nil { + t.Fatalf("invalid native response accepted %s", raw) + } + } +} + +func TestLoadIsAbsentOnlyForMissingMetadata(t *testing.T) { + home, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if value, err := Load(home); err != nil || value != nil { + t.Fatal(value, err) + } + root := filepath.Join(home, ".local/share/agent-heavy") + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + filename := filepath.Join(root, "installation.json") + valid := Installation{SchemaVersion: 1, App: filepath.Join(home, "Applications/Agent Queue.app"), ServiceManagement: true} + raw, err := json.Marshal(valid) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filename, raw, 0600); err != nil { + t.Fatal(err) + } + if actual, err := Load(home); err != nil || actual == nil || *actual != valid { + t.Fatal(actual, err) + } + for _, invalid := range []string{ + `{}`, `{"schemaVersion":1,"app":"/tmp/Agent Queue.app","serviceManagement":true}`, + `{"schemaVersion":1,"app":"/Applications/Agent Queue.app","serviceManagement":false}`, + `{"schemaVersion":1,"app":"/Applications/Agent Queue.app","serviceManagement":true,"schemaVersion":1}`, + `{"schemaVersion":1,"app":"/Applications/Agent Queue.app","serviceManagement":true,"unknown":true}`, + } { + if err := os.WriteFile(filename, []byte(invalid), 0600); err != nil { + t.Fatal(err) + } + if _, err := Load(home); err == nil { + t.Fatal("invalid metadata accepted", invalid) + } + } +} + +func TestLoadAcceptsSystemTemporaryAliasAbovePrivateHome(t *testing.T) { + home, err := os.MkdirTemp("/tmp", "agent-queue-metadata-test-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(home) + value, err := Load(home) + if err != nil || value != nil { + t.Fatal("missing metadata below system alias did not report absent", value, err) + } +} + +func TestLoadRejectsSymlinksAndUnsafePrivateMetadata(t *testing.T) { + for _, kind := range []string{"parent-symlink", "file-symlink", "hardlink", "writable", "oversized"} { + t.Run(kind, func(t *testing.T) { + home, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + root := filepath.Join(home, ".local/share/agent-heavy") + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + file := filepath.Join(root, "installation.json") + raw := []byte(`{"schemaVersion":1,"app":"/Applications/Agent Queue.app","serviceManagement":true}`) + if err := os.WriteFile(file, raw, 0600); err != nil { + t.Fatal(err) + } + switch kind { + case "parent-symlink": + if err := os.Rename(root, root+"-real"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(root+"-real", root); err != nil { + t.Fatal(err) + } + case "file-symlink": + if err := os.Rename(file, file+"-real"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(file+"-real", file); err != nil { + t.Fatal(err) + } + case "hardlink": + if err := os.Link(file, file+"-link"); err != nil { + t.Fatal(err) + } + case "writable": + if err := os.Chmod(file, 0666); err != nil { + t.Fatal(err) + } + case "oversized": + if err := os.WriteFile(file, make([]byte, 16385), 0600); err != nil { + t.Fatal(err) + } + } + if _, err := Load(home); err == nil { + t.Fatal("unsafe metadata accepted", kind) + } + }) + } +} + +func TestCanonicalHelperAndArgumentsContract(t *testing.T) { + app := "/Applications/Agent Queue.app" + for _, name := range []string{"agent-heavy", "agent-remote"} { + if bundle, ok := BundleForExecutable(filepath.Join(app, "Contents/MacOS", name)); !ok || bundle != app { + t.Fatal(bundle, ok) + } + } + for _, path := range []string{app + "/Contents/Resources/payload/source/bin/agent-heavy", app + "/Contents/MacOS/../MacOS/agent-heavy", app + "/Contents/MacOS/AgentQueue", "relative.app/Contents/MacOS/agent-heavy"} { + if _, ok := BundleForExecutable(path); ok { + t.Fatal("noncanonical helper accepted", path) + } + } + for _, action := range []string{"status", "register", "unregister"} { + for _, service := range []string{"queue", "resources", "remote", "login"} { + args, err := Arguments(app, action, service) + if err != nil || len(args) != 4 || args[0] != app+"/Contents/MacOS/AgentQueue" || args[1] != "--service-management" { + t.Fatal(args, err) + } + } + } + for _, args := range [][3]string{{app, "enable", "queue"}, {app, "status", "unknown"}, {"relative.app", "status", "queue"}, {"/Applications/../Bad.app", "status", "queue"}} { + if _, err := Arguments(args[0], args[1], args[2]); err == nil { + t.Fatal("invalid native command accepted", args) + } + } +} diff --git a/internal/appservices/deadlines.go b/internal/appservices/deadlines.go new file mode 100644 index 0000000..3273d32 --- /dev/null +++ b/internal/appservices/deadlines.go @@ -0,0 +1,14 @@ +package appservices + +import "time" + +const ( + // Read-only status never waits for asynchronous native shutdown. + NativeStatusTimeout = 5 * time.Second + // The bridge owns a 30-second completion bound plus process-start margin. + NativeCommandTimeout = 35 * time.Second + // A phone restart can unregister and register, query status four times, + // and spend 30 seconds on owned-process shutdown and readiness. + ControllerLifecycleTimeout = 2*NativeCommandTimeout + 4*NativeStatusTimeout + 30*time.Second + ControllerCommandTimeout = ControllerLifecycleTimeout + 5*time.Second +) diff --git a/internal/backend/common.go b/internal/backend/common.go index 07b4dbc..a8f0eae 100644 --- a/internal/backend/common.go +++ b/internal/backend/common.go @@ -3,6 +3,7 @@ package backend import ( "bytes" + "context" "crypto/rand" "encoding/hex" "encoding/json" @@ -261,6 +262,7 @@ type Gate struct { Config, State, Slot, Jobs, Receipts, Cancelled, Metadata string ControlEnv map[string]string ControlDeadline time.Time + controlContext context.Context queueControl func(string, Object) (Object, error) DescriptionCache, ProjectCache map[string]Object } @@ -349,6 +351,13 @@ func (b *boundedOutput) Write(p []byte) (int, error) { func (g *Gate) Snapshot() (Object, error) { return g.QueueControl("snapshot", nil) } + +func (g *Gate) operationContext() context.Context { + if g.controlContext != nil { + return g.controlContext + } + return context.Background() +} func (g *Gate) Task(id int) (Object, error) { snapshot, err := g.Snapshot() if err != nil { @@ -366,6 +375,10 @@ func (g *Gate) Task(id int) (Object, error) { } func LockPrivate(g *Gate, name string) (func(), error) { + ctx := g.operationContext() + if err := ctx.Err(); err != nil { + return nil, err + } if name == "" { name = "lease.lock" } @@ -390,6 +403,10 @@ func LockPrivate(g *Gate, name string) (func(), error) { } deadline := time.Now().Add(10 * time.Second) for { + if err := ctx.Err(); err != nil { + file.Close() + return nil, err + } err = syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB) if err == nil { break @@ -402,7 +419,12 @@ func LockPrivate(g *Gate, name string) (func(), error) { file.Close() return nil, errors.New("Queue coordination lock did not become available") } - time.Sleep(50 * time.Millisecond) + select { + case <-ctx.Done(): + file.Close() + return nil, ctx.Err() + case <-time.After(50 * time.Millisecond): + } } return func() { _ = syscall.Flock(fd, syscall.LOCK_UN); _ = file.Close() }, nil } diff --git a/internal/backend/install.go b/internal/backend/install.go index 0157f40..25bb5a9 100644 --- a/internal/backend/install.go +++ b/internal/backend/install.go @@ -18,6 +18,7 @@ import ( "strings" "time" + "github.com/Dunky13/agent-job-queue/internal/appservices" "golang.org/x/sys/unix" "gopkg.in/yaml.v3" "howett.net/plist" @@ -29,7 +30,11 @@ type installer struct { serviceSlot string testing bool appBundle bool + managed bool + queueSnapshot func(context.Context) (Object, error) disablePhoneAccess bool + prepareReplacement bool + unregisterServices bool command func(context.Context, []string) ([]byte, error) } @@ -133,6 +138,18 @@ func (install *installer) executable(name string) (string, error) { } func installCommandContext(ctx context.Context, args []string) (context.Context, context.CancelFunc) { + if len(args) > 1 && (args[1] == "--service-management" || args[1] == "--prepare-app-replacement") { + // The native bridge has its own 30-second completion deadline. Allow + // startup and delivery of its failure result before the outer bound. + timeout := appservices.NativeCommandTimeout + if len(args) == 4 && args[1] == "--service-management" && args[2] == "status" { + timeout = appservices.NativeStatusTimeout + } + return context.WithTimeout(ctx, timeout) + } + if len(args) == 2 && filepath.Base(args[0]) == "agent-remote" && slices.Contains([]string{"migrate", "disable"}, args[1]) { + return context.WithTimeout(ctx, appservices.ControllerCommandTimeout) + } return context.WithTimeout(ctx, 30*time.Second) } @@ -140,7 +157,8 @@ func (install *installer) commandEnvironment(args []string) []string { environment := envMap() environment["PATH"] = install.path environment["HOMEBREW_NO_AUTO_UPDATE"] = "1" - if slices.Equal(args, []string{filepath.Join(install.payload, "source/bin/agent-remote"), "disable"}) || len(args) > 0 && args[0] == "pueue" { + managedRemote := len(args) == 2 && (args[1] == "disable" || args[1] == "migrate") && (args[0] == filepath.Join(install.app, "Contents/MacOS/agent-remote") || args[0] == filepath.Join(install.payload, "Agent Queue.app/Contents/MacOS/agent-remote")) + if managedRemote || slices.Equal(args, []string{filepath.Join(install.payload, "source/bin/agent-remote"), "disable"}) || len(args) > 0 && args[0] == "pueue" { // Recovery targets this installation, including when launched from a // developer shell with private remote-controller fixture overrides. for key := range environment { @@ -150,7 +168,8 @@ func (install *installer) commandEnvironment(args []string) []string { } environment["HOME"] = install.home } - if slices.Equal(args, []string{filepath.Join(install.root, "bin/agent-heavy"), "status", "--json"}) { + replacement := len(args) == 3 && args[1] == "--prepare-app-replacement" && args[2] == install.app && (args[0] == filepath.Join(install.app, "Contents/MacOS/AgentQueue") || args[0] == filepath.Join(install.payload, "Agent Queue.app/Contents/MacOS/AgentQueue")) + if replacement || slices.Equal(args, []string{filepath.Join(install.root, "bin/agent-heavy"), "status", "--json"}) || slices.Equal(args, []string{filepath.Join(install.app, "Contents/MacOS/agent-heavy"), "status", "--json"}) || len(args) > 1 && args[0] == filepath.Join(install.app, "Contents/MacOS/AgentQueue") && args[1] == "--service-management" { // Installation always targets this user's installed state. An ambient // developer queue override must never establish readiness for that target. for key := range environment { @@ -172,6 +191,14 @@ func (install *installer) run(ctx context.Context, args ...string) ([]byte, erro if install.command != nil { return install.command(ctx, args) } + environmentArgs := args + if install.testing && len(args) > 1 && (args[1] == "--service-management" || args[1] == "--prepare-app-replacement") { + privateArgs, err := install.privateManagedCommand(args) + if err != nil { + return nil, err + } + args = privateArgs + } commandPath := args[0] if !filepath.IsAbs(commandPath) { var err error @@ -183,7 +210,7 @@ func (install *installer) run(ctx context.Context, args ...string) ([]byte, erro bounded, cancel := installCommandContext(ctx, args) defer cancel() command := exec.CommandContext(bounded, commandPath, args[1:]...) - command.Env = install.commandEnvironment(args) + command.Env = install.commandEnvironment(environmentArgs) output := &boundedOutput{limit: 4 * 1024 * 1024} command.Stdout = output command.Stderr = output @@ -207,7 +234,7 @@ func installRemoteServiceAbsent(output []byte, exitCode int) bool { } func installServiceAbsent(output []byte, exitCode int, label string) bool { - if !slices.Contains([]string{"io.went.agent-remote", "io.went.agent-heavy", "io.went.agent-resources"}, label) { + if !slices.Contains([]string{"io.went.agent-remote", "io.went.agent-heavy", "io.went.agent-resources", "io.went.agent-queue"}, label) { return false } expected := fmt.Sprintf("Bad request.\nCould not find service \"%s\" in domain for user gui: %d", label, os.Getuid()) @@ -318,6 +345,23 @@ func installReadInfo(filename string) (BuildInfo, error) { return ValidateBuildInfo(raw) } +// A downloaded payload replaces the recorded installation, including shared +// Applications and renamed bundles. An explicit app-bundle target stays explicit +// so the managed plan can refuse a mismatch rather than redirecting the caller. +func (install *installer) resolvePayloadApp() error { + if install.appBundle { + return nil + } + metadata, err := appservices.Load(install.home) + if err != nil { + return err + } + if metadata != nil { + install.app = metadata.App + } + return nil +} + func (install *installer) validateAppPath() error { if err := installValidateAppPath(install.home, install.app); err != nil { return err @@ -493,7 +537,29 @@ func (install *installer) validatePayload() error { if err := installValidatePayloadTree(boundary, install.appBundle); err != nil { return err } - for _, filename := range []string{filepath.Join(install.payload, "source/bin/agent-heavy"), filepath.Join(install.payload, "source/bin/agent-remote"), filepath.Join(app, "Contents/MacOS/AgentQueue")} { + metadata, err := install.readPayload(filepath.Join(app, "Contents/Info.plist"), 16384) + if err != nil { + return err + } + var packaging map[string]any + if _, err := plist.Unmarshal(metadata, &packaging); err != nil { + return err + } + install.managed = false + if marker, present := packaging["AgentQueueServiceManagement"]; present { + if marker != true { + return errors.New("Invalid service management packaging marker") + } + install.managed = true + if err := appservices.ValidateBundle(app); err != nil { + return err + } + } + executables := []string{filepath.Join(app, "Contents/MacOS/AgentQueue")} + if !install.managed || !install.appBundle { + executables = append(executables, filepath.Join(install.payload, "source/bin/agent-heavy"), filepath.Join(install.payload, "source/bin/agent-remote")) + } + for _, filename := range executables { if _, err := install.readPayload(filename, updateMaxArchive); err != nil { return err } @@ -525,6 +591,19 @@ func (install *installer) validatePayload() error { if !identity.Equal(bundled) { return errors.New("Installer app and support build identities differ") } + if install.managed { + raw, err := install.readPayload(filepath.Join(app, "Contents/Resources/payload/source/build-info.json"), 16384) + if err != nil { + return err + } + embedded, err := ValidateBuildInfo(raw) + if err != nil { + return err + } + if !identity.Equal(embedded) { + return errors.New("Managed app and embedded support identities differ") + } + } raw, err = install.readPayload(filepath.Join(app, "Contents/Info.plist"), 16384) if err != nil { return err @@ -552,10 +631,27 @@ func (install *installer) validatePayload() error { if !exists { continue } - installed, err := installReadInfo(marker) + var installed BuildInfo + if strings.HasPrefix(marker, install.app+"/") { + data, readErr := installReadPayload(install.app, marker, 16384, true) + if readErr != nil { + return readErr + } + installed, err = ValidateBuildInfo(data) + } else { + installed, err = installReadInfo(marker) + } if err != nil { return err } + if install.unregisterServices { + // Removal verifies identities but never activates code. An installed + // old app can still be removed after preparation of a newer candidate. + continue + } + if install.managed && installed.Build != nil && identity.Build == nil { + return errors.New("Refusing to replace a tracked completed or pending installation with an untracked build") + } if identity.Build != nil && installed.Build != nil { if *identity.Build < *installed.Build { return errors.New("Refusing to install an older tracked build over a newer installation") @@ -564,6 +660,9 @@ func (install *installer) validatePayload() error { return errors.New("The same tracked build number has a different commit or version") } } + if install.managed && marker == filepath.Join(install.root, "install-in-progress.json") && !identity.Equal(installed) { + return errors.New("Another candidate installation is pending; retry that exact build before replacing it") + } } return nil } @@ -665,6 +764,9 @@ func (install *installer) standardCleanupCallback(callback string) bool { if slices.Contains(installCallbackPathSpellings(filepath.Join(install.root, "bin/agent-heavy")), invocation) { return true } + if install.managed && slices.Contains(installCallbackPathSpellings(filepath.Join(install.home, ".local/bin/agent-heavy")), invocation) { + return true + } // The legacy installer used a venv and Homebrew Python 3.12. Retain only // these and standard Python 3 locations on Apple Silicon and Intel Macs; // arbitrary basenames, wrappers, Cellar paths and flags are not equivalent. @@ -1292,6 +1394,10 @@ func installLoadedServiceMatches(output []byte, expected []string) bool { if len(expected) == 0 { return false } + return installLoadedServiceProgramMatches(output, expected[0], expected) +} + +func installLoadedServiceProgramMatches(output []byte, program string, expected []string) bool { programMatches := false arguments := []string{} inArguments := false @@ -1300,7 +1406,7 @@ func installLoadedServiceMatches(output []byte, expected []string) bool { line = strings.TrimSpace(line) if strings.HasPrefix(line, "program = ") { programs++ - programMatches = line == "program = "+expected[0] + programMatches = line == "program = "+program } if line == "arguments = {" { blocks++ @@ -1735,9 +1841,26 @@ func (install *installer) execute(ctx context.Context) error { if !install.testing && runtime.GOOS != "darwin" { return errors.New("Agent Job Queue requires macOS") } + if err := install.resolvePayloadApp(); err != nil { + return err + } if err := install.validatePayload(); err != nil { return err } + if install.prepareReplacement || install.unregisterServices { + if !install.managed || install.prepareReplacement && install.appBundle || install.unregisterServices && !install.appBundle || install.prepareReplacement && install.unregisterServices { + return errors.New("Replacement preparation requires a marked managed payload; service removal requires a marked installed app bundle") + } + return install.prepareManaged(ctx) + } + if install.managed { + return install.executeManaged(ctx) + } + if metadata, err := appservices.Load(install.home); err != nil { + return err + } else if metadata != nil { + return errors.New("A managed installation cannot be replaced with an unmarked legacy payload") + } directories := []string{install.root, install.state, install.config, filepath.Join(install.home, ".local/bin"), install.launch} if !install.appBundle { directories = append(directories, filepath.Join(install.home, "Applications")) @@ -2025,6 +2148,8 @@ func InstallMain(args []string) int { path = tools + string(os.PathListSeparator) + path } install := &installer{home: home, payload: args[1], root: filepath.Join(home, ".local/share/agent-heavy"), state: filepath.Join(home, ".local/state/agent-heavy"), config: filepath.Join(home, ".config/agent-heavy"), launch: filepath.Join(home, "Library/LaunchAgents"), app: filepath.Join(home, "Applications/Agent Queue.app"), path: path, testing: testing, disablePhoneAccess: disablePhoneAccess} + install.prepareReplacement = options.prepareReplacement + install.unregisterServices = options.unregisterServices if args[0] == "--app-bundle" { install.appBundle = true install.app = args[1] @@ -2052,6 +2177,14 @@ func InstallMain(args []string) int { } return 1 } + if options.prepareReplacement { + fmt.Println("Prepared Agent Queue replacement. Services are stopped; the application and CLI wrappers are unchanged.") + return 0 + } + if options.unregisterServices { + fmt.Println("Unregistered Agent Queue services. The application can now be removed; private data is preserved.") + return 0 + } fmt.Println("Installed Agent Queue. Your queue is private; manage optional phone access in settings.") fmt.Printf("CLI: %s\n", filepath.Join(home, ".local/bin/agent-heavy")) return 0 diff --git a/internal/backend/install_managed.go b/internal/backend/install_managed.go new file mode 100644 index 0000000..8f77a12 --- /dev/null +++ b/internal/backend/install_managed.go @@ -0,0 +1,1683 @@ +package backend + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "time" + + "github.com/Dunky13/agent-job-queue/internal/appservices" + "golang.org/x/sys/unix" + "gopkg.in/yaml.v3" + "howett.net/plist" +) + +type managedPlan struct { + previous *appservices.Installation + statuses map[string]string + legacy map[string]Object + loaded map[string]bool + orphanedMenu bool + node *yaml.Node + python, pueue, remote bool + remoteCleanup bool +} + +func (install *installer) managedStatus(ctx context.Context, app, service string) (string, error) { + args, err := appservices.Arguments(app, "status", service) + if err != nil { + return "", err + } + raw, err := install.run(ctx, args...) + if err != nil { + return "", err + } + status, err := appservices.DecodeStatus(raw, service) + return status.Status, err +} + +func (install *installer) managedAction(ctx context.Context, app, action, service string) error { + status, err := install.managedStatus(ctx, app, service) + if err != nil { + return err + } + if status == "requiresApproval" && !(action == "unregister" && install.unregisterServices) { + return fmt.Errorf("The %s service requires approval in System Settings; approve it before retrying setup", service) + } + if status == "notFound" { + return fmt.Errorf("The %s service definition was not found; setup is incomplete", service) + } + if action == "register" && status == "enabled" || action == "unregister" && status == "notRegistered" { + return nil + } + args, err := appservices.Arguments(app, action, service) + if err != nil { + return err + } + // The native bridge waits for asynchronous unregistration completion. + if _, err := install.run(ctx, args...); err != nil { + return err + } + status, err = install.managedStatus(ctx, app, service) + if err != nil { + return err + } + expected := "enabled" + if action == "unregister" { + expected = "notRegistered" + } + if status != expected { + return fmt.Errorf("The %s service is %s after %s; setup is incomplete", service, status, action) + } + return nil +} + +func (install *installer) managedVerify(ctx context.Context, app string) error { + if err := installValidatePayloadTree(app, true); err != nil { + return err + } + if err := appservices.ValidateBundle(app); err != nil { + return err + } + for _, target := range []string{filepath.Join(app, "Contents/MacOS/agent-heavy"), filepath.Join(app, "Contents/MacOS/agent-remote"), app} { + if _, err := install.run(ctx, "/usr/bin/codesign", "--verify", "--strict", "--verbose=2", target); err != nil { + return fmt.Errorf("Managed bundle signature verification failed: %w", err) + } + } + return nil +} + +// Permit only settings whose behavior the static bundle definitions retain. +// Unknown legacy options cannot be silently discarded during migration. +func (install *installer) managedLegacyDefinition(label string, value Object) error { + menu := label == "io.went.agent-queue" + expected := Object{"Label": label, "RunAtLoad": true, "ThrottleInterval": uint64(10), "Umask": uint64(077), "WorkingDirectory": install.state} + log := "resources.log" + if label == "io.went.agent-heavy" { + log = "daemon.log" + expected["KeepAlive"] = Object{"SuccessfulExit": false} + } else { + expected["KeepAlive"] = true + } + expected["StandardErrorPath"] = filepath.Join(install.state, log) + expected["StandardOutPath"] = filepath.Join(install.state, log) + if menu { + expected = Object{"Label": label, "RunAtLoad": true, "KeepAlive": false} + } + for key, raw := range value { + if key == "ProgramArguments" || key == "Program" { + continue + } + if key == "EnvironmentVariables" && !menu { + env := obj(raw) + if env == nil { + return errors.New("Invalid legacy service environment") + } + allowed := install.serviceEnvironment(nil) + allowed["PATH"] = ToolPath + for name, field := range env { + if !reflect.DeepEqual(field, allowed[name]) { + return fmt.Errorf("Legacy service environment %s cannot be retained by the managed bundle", name) + } + } + continue + } + if !managedValuesEqual(raw, expected[key]) { + return fmt.Errorf("Legacy service option %s is customized; review it before managed migration", key) + } + } + if menu { + args, err := stringsFrom(value["ProgramArguments"]) + if err != nil || len(args) != 3 || args[0] != "/usr/bin/open" || args[1] != "-a" || installValidateAppPath(install.home, args[2]) != nil { + return errors.New("Legacy menu service is customized") + } + // An explicit Finder target can differ from the old canonical app. The + // old menu command remains owned; never reinterpret it as the new target. + if args[2] != install.app && args[2] != filepath.Join(install.home, "Applications/Agent Queue.app") && args[2] != "/Applications/Agent Queue.app" { + raw, err := installReadPayload(args[2], filepath.Join(args[2], "Contents/Info.plist"), 16384, true) + if err != nil { + return err + } + var info Object + if _, err := plist.Unmarshal(raw, &info); err != nil || info["CFBundleIdentifier"] != "io.went.agent-queue" { + return errors.New("Legacy menu targets another application") + } + } + if program, exists := value["Program"]; exists && program != args[0] { + return errors.New("Legacy menu service overrides its executable") + } + } + return nil +} + +func managedValuesEqual(left, right any) bool { + a, err := json.Marshal(left) + if err != nil { + return false + } + b, err := json.Marshal(right) + return err == nil && string(a) == string(b) +} + +func (install *installer) managedSnapshot(ctx context.Context) (Object, error) { + if install.queueSnapshot != nil { + return install.queueSnapshot(ctx) + } + deadline := time.Now().Add(15 * time.Second) + if limit, ok := ctx.Deadline(); ok && limit.Before(deadline) { + deadline = limit + } + gate := &Gate{State: install.state, ControlDeadline: deadline} + return gate.Snapshot() +} + +func (install *installer) managedDrain(ctx context.Context, p *managedPlan) error { + for name, label := range map[string]string{"queue-daemon.lock": "io.went.agent-heavy", "resource-controller.lock": "io.went.agent-resources"} { + busy, err := installInspectLock(install.state, name) + if err != nil { + return err + } + if busy && !p.loaded[label] { + return errors.New("An unowned queue process holds its lifetime lock; setup refused before service mutation") + } + } + busy, err := installInspectLock(filepath.Join(install.state, "remote"), "supervisor.lock") + if err != nil { + return err + } + if busy && !p.loaded["io.went.agent-remote"] { + return errors.New("An unowned phone access process holds its lifetime lock; setup refused before service mutation") + } + if (install.prepareReplacement || install.unregisterServices) && !p.loaded["io.went.agent-remote"] { + cleanup, err := install.disabledRemoteCleanup(ctx) + if err != nil { + return err + } + if cleanup { + return errors.New("Phone access has retained process ownership records without an owned loaded service; disable phone access before retrying") + } + } + if p.pueue { + return nil + } // queueMigration already applies the Pueue drain and cleanup gates. + exists, err := updateExists(filepath.Join(install.state, "queue.sqlite3")) + if err != nil { + return err + } + if !p.loaded["io.went.agent-heavy"] && !exists { + return nil + } + if !p.loaded["io.went.agent-heavy"] { + return install.managedOfflineDrain(ctx) + } + snapshot, err := install.managedSnapshot(ctx) + if err != nil { + return fmt.Errorf("Cannot verify the installed queue is drained; no service was changed: %w", err) + } + tasks := obj(snapshot["tasks"]) + if tasks == nil { + return errors.New("Cannot verify drained queue: invalid queue snapshot") + } + if err := installRequireDrained(tasks); err != nil { + return err + } + return install.requireLegacyCleanup() +} + +// Inspect a stopped queue using private copies. SQLite may create sidecars even +// in read-only mode, so never open the user's live database during preflight. +func (install *installer) managedOfflineDrain(ctx context.Context) error { + for _, name := range []string{"queue-daemon.lock", "resource-controller.lock"} { + busy, err := installInspectLock(install.state, name) + if err != nil { + return err + } + if busy { + return errors.New("An unowned or stopping queue process still holds its lifetime lock; setup refused") + } + } + path := QueueDatabasePath(install.state) + if err := queuePrivateFiles(path); err != nil { + return err + } + temporary, err := os.MkdirTemp("", "agent-queue-install-inspect-") + if err != nil { + return err + } + defer os.RemoveAll(temporary) + for _, suffix := range []string{"", "-journal", "-wal", "-shm"} { + raw, err := appservices.ReadFile(install.home, path+suffix, updateMaxArchive, false) + if errors.Is(err, os.ErrNotExist) && suffix != "" { + continue + } + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(temporary, "queue.sqlite3"+suffix), raw, 0600); err != nil { + return err + } + } + db, err := sql.Open("sqlite", filepath.Join(temporary, "queue.sqlite3")) + if err != nil { + return err + } + defer db.Close() + db.SetMaxOpenConns(1) + var version, unfinished int + if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { + return err + } + if version != 1 && version != 2 { + return errors.New("Unsupported stopped queue schema") + } + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM queue_jobs WHERE phase != 'done'").Scan(&unfinished); err != nil { + return err + } + if unfinished != 0 { + return errors.New("Managed setup requires a drained queue; stopped queue has unfinished jobs or attempts") + } + store := &queueStore{db: db, path: filepath.Join(temporary, "queue.sqlite3")} + snapshot, err := store.snapshot() + if err != nil { + return err + } + if err := installRequireDrained(obj(snapshot["tasks"])); err != nil { + return err + } + return install.requireLegacyCleanup() +} + +func (install *installer) planManaged(ctx context.Context) (*managedPlan, error) { + p := &managedPlan{statuses: map[string]string{}, legacy: map[string]Object{}, loaded: map[string]bool{}} + if _, err := install.managedWrappers(); err != nil { + return nil, err + } + var err error + p.previous, err = appservices.Load(install.home) + if err != nil { + return nil, err + } + if p.previous != nil { + if p.previous.App != install.app { + return nil, errors.New("Managed app location changed; restore its recorded Applications location before retrying") + } + if err := appservices.ValidateApp(install.home, p.previous.App); err != nil { + return nil, err + } + } + if err := install.managedPublicationAccess(); err != nil { + return nil, err + } + if exists, err := updateExists(filepath.Join(install.config, "pueue.yml")); err != nil { + return nil, err + } else if exists { + p.node, err = installConfig(filepath.Join(install.config, "pueue.yml")) + if err != nil { + return nil, err + } + callback, err := installConfigCallback(p.node) + if err != nil { + return nil, err + } + if !install.standardCleanupCallback(callback) { + return nil, errors.New("Custom cleanup callback cannot be retained by managed migration; review it before retrying") + } + } + if err := install.validateServices(); err != nil { + return nil, err + } + for _, label := range []string{"io.went.agent-heavy", "io.went.agent-resources", "io.went.agent-queue"} { + filename := filepath.Join(install.launch, label+".plist") + if exists, err := updateExists(filename); err != nil { + return nil, err + } else if exists { + definition, err := updatePlist(filename) + if err != nil { + return nil, err + } + if err := install.managedLegacyDefinition(label, definition); err != nil { + return nil, err + } + p.legacy[label] = definition + } + output, loaded, err := install.inspectService(ctx, label) + if err != nil { + return nil, err + } + p.loaded[label] = loaded + if !loaded { + continue + } + if definition := p.legacy[label]; definition != nil { + args, err := stringsFrom(definition["ProgramArguments"]) + if err != nil || !installLoadedServiceMatches(output, args) { + return nil, errors.New("Loaded legacy service ownership differs; no process was stopped") + } + } else { + if label == "io.went.agent-queue" { + // Historical Homebrew removal deleted this plist without bootout. + // Accept only the exact canonical old open command, including its + // real executable. A matching label alone establishes no custody. + if p.previous != nil { + return nil, errors.New("Loaded menu launch service has no owned definition") + } + for _, app := range []string{install.app, filepath.Join(install.home, "Applications/Agent Queue.app"), "/Applications/Agent Queue.app"} { + arguments := []string{"/usr/bin/open", "-a", app} + if installLoadedServiceProgramMatches(output, "/usr/bin/open", arguments) { + p.legacy[label] = Object{"Label": label, "ProgramArguments": arguments} + p.orphanedMenu = true + break + } + } + if !p.orphanedMenu { + return nil, errors.New("Loaded menu launch service has no owned canonical command") + } + continue + } + // A pending retry can have registered the bundle before metadata was + // published. The signed target plus exact loaded argv establishes ownership. + if err := install.managedLoaded(output, label, install.app); err != nil { + return nil, err + } + if p.previous == nil { + if exists, err := updateExists(filepath.Join(install.root, "install-in-progress.json")); err != nil || !exists { + return nil, errors.New("Loaded managed service has no installation custody metadata") + } + } + } + } + // Inspect registrations even for a first install: a Finder-copied bundle may + // already own services. Status is read-only; denied approval is never retried. + statusApp := install.app + if !install.appBundle { + if exists, err := updateExists(install.app); err != nil { + return nil, err + } else if !exists { + statusApp = "" + } + } + if statusApp != "" { + raw, err := installReadPayload(statusApp, filepath.Join(statusApp, "Contents/Info.plist"), 16384, true) + if err != nil { + return nil, err + } + var info Object + if _, err := plist.Unmarshal(raw, &info); err != nil { + return nil, err + } + if info["AgentQueueServiceManagement"] == true { + if err := install.managedVerify(ctx, statusApp); err != nil { + return nil, err + } + for _, service := range []string{"queue", "resources", "remote", "login"} { + status, err := install.managedStatus(ctx, statusApp, service) + if err != nil { + return nil, err + } + if status == "requiresApproval" && !install.unregisterServices || status == "notFound" { + return nil, fmt.Errorf("The %s service is %s; resolve it before retrying setup", service, status) + } + p.statuses[service] = status + } + } + } + if p.previous != nil { + p.python, err = updateExists(filepath.Join(install.root, "agent_heavy.py")) + } else { + p.python, err = install.pythonMigration(ctx, p.node) + } + if err != nil { + return nil, err + } + if p.python { + if err := install.validateRetirement(); err != nil { + return nil, err + } + } + if p.previous == nil { + p.pueue, err = install.queueMigration(ctx, p.node) + if err != nil { + return nil, err + } + } + p.remote, err = install.remoteEnabled() + if err != nil { + return nil, err + } + legacyRemote, err := updateExists(filepath.Join(install.launch, "io.went.agent-remote.plist")) + if err != nil { + return nil, err + } + if p.previous == nil || legacyRemote { + if err := install.validatePhoneAccessRecovery(ctx); err != nil { + return nil, err + } + } + if legacyRemote { + definition, err := updatePlist(filepath.Join(install.launch, "io.went.agent-remote.plist")) + if err != nil { + return nil, err + } + p.legacy["io.went.agent-remote"] = definition + } + if output, loaded, err := install.inspectService(ctx, "io.went.agent-remote"); err != nil { + return nil, err + } else { + p.loaded["io.went.agent-remote"] = loaded + if loaded && p.previous != nil && !legacyRemote { + if err := install.managedLoaded(output, "io.went.agent-remote", install.app); err != nil { + return nil, err + } + } + } + if !p.remote { + p.remoteCleanup, err = install.disabledRemoteCleanup(ctx) + if err != nil { + return nil, err + } + } + for _, name := range []string{"agent-heavy", "agent-remote"} { + if _, err := appservices.ReadFile(install.home, filepath.Join(install.root, "bin", name), updateMaxArchive, false); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + } + if err := install.managedDrain(ctx, p); err != nil { + return nil, err + } + if p.previous == nil && (p.pueue || p.python) { + report := installPreflightReport{} + install.preflightLegacyProcesses(ctx, &report, true) + if len(report.Issues) != 0 { + return nil, errors.New(report.Issues[0].Detail) + } + } + return p, nil +} + +func (install *installer) managedLoaded(raw []byte, label, app string) error { + helper, command := "agent-heavy", "daemon" + if label == "io.went.agent-resources" { + command = "scheduler" + } + if label == "io.went.agent-remote" { + helper, command = "agent-remote", "serve" + } + executable := filepath.Join(app, "Contents/MacOS", helper) + for _, first := range []string{executable, helper, "Contents/MacOS/" + helper} { + if installLoadedServiceProgramMatches(raw, executable, []string{first, command}) { + return nil + } + } + return errors.New("Loaded service differs from the canonical managed helper; no process was stopped") +} + +func (install *installer) stopManagedLegacy(ctx context.Context, p *managedPlan) error { + if definition := p.legacy["io.went.agent-remote"]; definition != nil { + if err := install.validatePhoneAccessRecovery(ctx); err != nil { + return err + } + current, err := updatePlist(filepath.Join(install.launch, "io.went.agent-remote.plist")) + if err != nil { + return err + } + if !reflect.DeepEqual(current, definition) { + return errors.New("Phone service definition changed before shutdown") + } + if _, loaded, err := install.inspectService(ctx, "io.went.agent-remote"); err != nil { + return err + } else if loaded { + if _, err := install.run(ctx, "launchctl", "bootout", install.domain()+"/io.went.agent-remote"); err != nil { + return err + } + } + } + if p.pueue || p.python && !install.embeddedDefinition() { + if err := install.stopLegacy(ctx, p.node); err != nil { + return err + } + } else { + for _, label := range []string{"io.went.agent-resources", "io.went.agent-heavy"} { + if p.legacy[label] == nil || !p.loaded[label] { + continue + } + current, err := updatePlist(filepath.Join(install.launch, label+".plist")) + if err != nil { + return err + } + if !reflect.DeepEqual(current, p.legacy[label]) { + return errors.New("Legacy definition changed before shutdown; no uncertain process was stopped") + } + output, loaded, err := install.inspectService(ctx, label) + if err != nil { + return err + } + if !loaded { + continue + } + arguments, err := stringsFrom(current["ProgramArguments"]) + if err != nil || !installLoadedServiceMatches(output, arguments) { + return errors.New("Loaded legacy ownership changed before shutdown; no uncertain process was stopped") + } + if _, err := install.run(ctx, "launchctl", "bootout", install.domain()+"/"+label); err != nil { + return err + } + } + } + if p.legacy["io.went.agent-queue"] != nil && p.loaded["io.went.agent-queue"] { + if p.orphanedMenu { + if exists, err := updateExists(filepath.Join(install.launch, "io.went.agent-queue.plist")); err != nil { + return err + } else if exists { + return errors.New("Menu definition reappeared before shutdown; review its ownership") + } + } + output, loaded, err := install.inspectService(ctx, "io.went.agent-queue") + if err != nil { + return err + } + arguments, err := stringsFrom(p.legacy["io.went.agent-queue"]["ProgramArguments"]) + if err != nil || loaded && !installLoadedServiceMatches(output, arguments) { + return errors.New("Loaded menu ownership changed before shutdown") + } + if _, err := install.run(ctx, "launchctl", "bootout", install.domain()+"/io.went.agent-queue"); err != nil { + return err + } + } + for label := range p.legacy { + if _, loaded, err := install.inspectService(ctx, label); err != nil { + return err + } else if loaded { + return errors.New("Legacy service is still loaded; managed registration refused") + } + // Re-read before removing: never unlink a definition replaced by another operator. + filename := filepath.Join(install.launch, label+".plist") + current, err := updatePlist(filename) + if p.orphanedMenu && label == "io.went.agent-queue" && errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + if !reflect.DeepEqual(current, p.legacy[label]) { + return errors.New("Legacy definition changed during migration") + } + parent, err := integrationParent(filename, false) + if err != nil { + return err + } + err = unix.Unlinkat(int(parent.Fd()), filepath.Base(filename), 0) + if err == nil { + err = parent.Sync() + } + parent.Close() + if err != nil { + return err + } + } + // The daemon and scheduler lifetime locks prove that detached instances have + // finished. Taking a lock never authorizes killing an uncertain process. + for _, name := range []string{"queue-daemon.lock", "resource-controller.lock"} { + lock, err := installLock(install.state, name) + if err != nil { + return fmt.Errorf("Old service has not stopped: %w", err) + } + lock.Close() + } + return nil +} + +func managedBundleDigest(app string) ([32]byte, error) { + hash := sha256.New() + err := filepath.WalkDir(app, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(app, path) + if err != nil { + return err + } + fmt.Fprintf(hash, "%d:%s\x00", len(relative), relative) + if entry.IsDir() { + return nil + } + if !entry.Type().IsRegular() { + return errors.New("Unsafe bundle entry") + } + raw, err := installReadPayload(app, path, updateMaxArchive, true) + if err != nil { + return err + } + fmt.Fprintf(hash, "%d:", len(raw)) + _, err = hash.Write(raw) + return err + }) + var result [32]byte + copy(result[:], hash.Sum(nil)) + return result, err +} + +// Shared Applications is a system-owned parent, never a private directory to +// chmod. Refuse a payload replacement this account cannot finish before stopping +// services; immutable root-owned bundles remain supported for explicit setup. +func (install *installer) managedPublicationAccess() error { + parent := filepath.Dir(install.app) + publication, err := install.managedPublicationParent(false) + if err != nil { + return err + } + defer publication.Close() + if err := install.managedLifecycleCustody(); err != nil { + return err + } + if install.appBundle { + if err := install.managedBackupAccess(); err != nil { + return err + } + if exists, err := updateExists(install.managedPreviousPath()); err != nil { + return err + } else if exists { + if err := unix.Faccessat(int(publication.Fd()), ".", unix.W_OK|unix.X_OK, 0); err != nil { + return errors.New("Completing the retained publication requires write access to its application directory; no services were changed") + } + } + return nil // No publication writes are required when no backup is retained. + } + if install.prepareReplacement || install.unregisterServices { + return nil // These operations preserve app bundles rather than publish them. + } + if err := install.managedBackupAccess(); err != nil { + return err + } + if err := install.managedStagingAccess(publication); err != nil { + return err + } + if err := managedCopySourceAccess(filepath.Join(install.payload, "Agent Queue.app")); err != nil { + return err + } + if exists, err := updateExists(parent); err != nil { + return err + } else if exists { + if err := unix.Access(parent, unix.W_OK|unix.X_OK); err != nil { + return errors.New("Application directory is not writable by this account; no services were changed") + } + } + if exists, err := updateExists(install.app); err != nil { + return err + } else if !exists { + return nil + } + if err := installValidatePayloadTree(install.app, false); err != nil { + return fmt.Errorf("Application cannot be replaced by this account; no services were changed: %w", err) + } + return filepath.WalkDir(install.app, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + access := uint32(unix.W_OK) + if entry.IsDir() { + access |= unix.X_OK + } + if err := unix.Access(path, access); err != nil { + return errors.New("Application code is not writable by this account; no services were changed") + } + return nil + }) +} + +// Publish a verified candidate before registration, retaining the previous +// bundle until the entire installation has become ready and published identity. +func (install *installer) publishManaged(ctx context.Context) (result error) { + if err := install.validatePayload(); err != nil { + return err + } + expected := install.identity + if err := install.managedPublicationAccess(); err != nil { + return err + } + if install.appBundle { + before, err := managedCaptureBundle(install.app) + if err != nil { + return err + } + if err := install.managedVerify(ctx, install.app); err != nil { + return err + } + if err := install.managedValidateExpected(expected); err != nil { + return err + } + return managedRequireBundle(install.app, before) + } + parent, err := install.managedPublicationParent(true) + if err != nil { + return err + } + defer parent.Close() + source := filepath.Join(install.payload, "Agent Queue.app") + selected, err := managedCaptureBundle(source) + if err != nil { + return err + } + if !selected.Build.Equal(expected) { + return errors.New("Managed source differs from the expected pending candidate") + } + var old *managedBundleIdentity + if exists, err := updateExists(install.app); err != nil { + return err + } else if exists { + current, err := managedCaptureBundle(install.app) + if err != nil { + return err + } + old = ¤t + // The backup/receipt gate above also applies to this retry shortcut. + if selected.Digest == current.Digest { + if err := install.managedVerify(ctx, install.app); err != nil { + return err + } + if err := install.managedValidateExpected(expected); err != nil { + return err + } + if err := managedRequireBundle(source, selected); err != nil { + return err + } + return managedRequireBundle(install.app, current) + } + } + if exists, err := updateExists(install.managedPreviousPath()); err != nil { + return err + } else if exists { + return errors.New("A previous application is retained from incomplete setup; retry that exact installer before another replacement") + } + staging := filepath.Join(filepath.Dir(install.app), ".Agent Queue.installing-"+token()+".app") + if err := install.managedCopyApp(ctx, source, staging); err != nil { + return err + } + // Hold the actual stage root until cleanup so inode recycling cannot turn a + // replacement directory into one of our owned objects. + stage, err := installPayloadParent(staging, filepath.Join(staging, ".custody-probe"), true) + if err != nil { + return err + } + defer stage.Close() + var ownedReceipt []byte + defer func() { + if _, err := os.Lstat(staging); errors.Is(err, os.ErrNotExist) { + return + } + if err := managedCopyNamed(parent, filepath.Base(staging), stage); err != nil { + result = errors.Join(result, fmt.Errorf("Staged application custody changed; evidence preserved: %w", err)) + return + } + if old == nil && ownedReceipt != nil { + if exists, err := updateExists(install.app); err != nil || exists { + result = errors.Join(result, errors.New("Publication target appeared before stage cleanup; receipt and stage preserved"), err) + return + } + if err := install.managedRemoveFile(install.managedReceiptPath(), ownedReceipt); err != nil { + result = errors.Join(result, err) + return + } + } + root, err := os.OpenRoot(filepath.Dir(staging)) + if err != nil { + result = errors.Join(result, err) + return + } + result = errors.Join(result, root.RemoveAll(filepath.Base(staging))) + root.Close() + }() + candidate, err := managedCaptureBundle(staging) + if err != nil { + return err + } + if candidate.Digest != selected.Digest || !candidate.Build.Equal(expected) { + return errors.New("Managed staged candidate differs from the expected pending build") + } + // Source, pending identity, target ordering and exact old object are checked + // again after all staging/signature work, immediately before either rename. + if err := install.managedValidateExpected(expected); err != nil { + return err + } + if err := managedRequireBundle(source, selected); err != nil { + return err + } + if err := install.managedPublicationAccess(); err != nil { + return err + } + if err := install.managedCheckPublicationParent(parent); err != nil { + return err + } + if old != nil { + if err := managedRequireBundle(install.app, *old); err != nil { + return err + } + } else if exists, err := updateExists(install.app); err != nil { + return err + } else if exists { + return errors.New("Managed publication target appeared during staging; evidence preserved") + } + if err := managedRequireBundle(staging, candidate); err != nil { + return err + } + receipt := managedPublicationReceipt{SchemaVersion: 1, App: install.app, Pending: expected, Candidate: candidate, Backup: old} + raw, err := json.Marshal(receipt) + if err != nil { + return err + } + ownedReceipt = append(raw, '\n') + if err := installAtomic(install.managedReceiptPath(), ownedReceipt, 0600); err != nil { + ownedReceipt = nil + return err + } + if old != nil { + if err := unix.Renameat(int(parent.Fd()), filepath.Base(install.app), int(parent.Fd()), filepath.Base(install.managedPreviousPath())); err != nil { + return err + } + if err := parent.Sync(); err != nil { + return err // Receipt and last good backup remain recovery evidence. + } + } + if err := unix.Renameat(int(parent.Fd()), filepath.Base(staging), int(parent.Fd()), filepath.Base(install.app)); err != nil { + return err // Recovery uses the exact durable receipt, never a blind restore. + } + if err := parent.Sync(); err != nil { + return err + } + return managedRequireBundle(install.app, candidate) +} + +func (install *installer) managedSupport() error { + if _, err := install.managedWrappers(); err != nil { + return err + } + for _, name := range []string{"agent-heavy", "agent-remote"} { + if err := installAtomic(filepath.Join(install.home, ".local/bin", name), install.managedWrapper(name), 0700); err != nil { + return err + } + } + return nil +} + +func (install *installer) managedWrapper(name string) []byte { + return []byte("#!/bin/sh\nexec " + installQuote(filepath.Join(install.app, "Contents/MacOS", name)) + " \"$@\"\n") +} + +// Exact generated content is required in addition to no-follow ownership +// checks. A private custom script is not permission to overwrite or delete it. +func (install *installer) managedWrappers() (map[string][]byte, error) { + wrappers := map[string][]byte{} + for _, name := range []string{"agent-heavy", "agent-remote"} { + filename := filepath.Join(install.home, ".local/bin", name) + raw, err := appservices.ReadFile(install.home, filename, 16384, false) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + legacy := []byte("#!/bin/sh\nexec \"$HOME/.local/share/agent-heavy/bin/" + name + "\" \"$@\"\n") + pythonModule := "agent_heavy.py" + if name == "agent-remote" { + pythonModule = "remote/control.py" + } + // Exact wrappers shipped before the Go migration (452f076's parent). + python := []byte("#!/bin/sh\nset -eu\nroot=\"${HOME:?}/.local/share/agent-heavy\"\nexec \"$root/.venv/bin/python\" \"$root/" + pythonModule + "\" \"$@\"\n") + if !bytes.Equal(raw, install.managedWrapper(name)) && !bytes.Equal(raw, legacy) && !bytes.Equal(raw, python) { + return nil, fmt.Errorf("CLI wrapper %s is customized; no service was stopped", name) + } + wrappers[filename] = raw + } + return wrappers, nil +} + +func (install *installer) managedRemoveFile(filename string, expected []byte) error { + raw, err := appservices.ReadFile(install.home, filename, updateMaxArchive, false) + if err != nil { + return err + } + if !bytes.Equal(raw, expected) { + return errors.New("Installation file changed before retirement; evidence preserved") + } + parent, err := integrationParent(filename, false) + if err != nil { + return err + } + defer parent.Close() + if err := unix.Unlinkat(int(parent.Fd()), filepath.Base(filename), 0); err != nil { + return err + } + return parent.Sync() +} + +// Preserve the newest verified version evidence even when removing an older +// app after preparation of a newer candidate. Removal must not reset ordering. +func (install *installer) managedRemovalIdentity() (BuildInfo, error) { + identity := install.identity + for _, name := range []string{"build-info.json", "install-in-progress.json"} { + other, err := installReadInfo(filepath.Join(install.root, name)) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return BuildInfo{}, err + } + if other.Build != nil && (identity.Build == nil || *other.Build > *identity.Build) { + identity = other + } else if other.Build != nil && identity.Build != nil && *other.Build == *identity.Build && !other.Equal(identity) { + return BuildInfo{}, errors.New("Conflicting tracked installation identities; removal refused") + } + } + return identity, nil +} + +func (install *installer) managedStopMenu(ctx context.Context, source string) error { + exists, err := updateExists(install.app) + if err != nil || !exists { + return err + } + output, err := install.run(ctx, filepath.Join(source, "Contents/MacOS/AgentQueue"), "--prepare-app-replacement", install.app) + if err != nil { + return fmt.Errorf("The previous menu app could not quit; preserve its bundle and retry after quitting: %w", err) + } + result, err := decodeObject(output) + schema, schemaOK := integer(result["schemaVersion"]) + if err != nil || !schemaOK || schema != 1 || result["app"] != install.app || result["status"] != "stopped" { + return errors.New("The previous menu app did not confirm shutdown; its bundle is preserved") + } + return nil +} + +// Preparation is an explicit mutation, separate from read-only preflight. The +// cask may move/delete its app artifact only after this operation succeeds. +func (install *installer) prepareManaged(ctx context.Context) error { + for _, directory := range []string{install.root, install.state, install.config, install.launch, filepath.Join(install.home, ".local/bin"), filepath.Join(install.state, "remote")} { + if err := installInspectDirectory(directory); err != nil { + return err + } + } + for _, entry := range []struct{ directory, name string }{{install.state, "install.lock"}, {install.state, "scheduling.lock"}, {filepath.Join(install.state, "remote"), "control.lock"}} { + busy, err := installInspectLock(entry.directory, entry.name) + if err != nil { + return err + } + if busy { + return errors.New("Another installer or queue change is running; finish it before trying again") + } + } + source := install.app + if !install.appBundle { + source = filepath.Join(install.payload, "Agent Queue.app") + } + if err := install.managedVerify(ctx, source); err != nil { + return err + } + if _, err := install.managedWrappers(); err != nil { + return err + } + if install.unregisterServices { + if _, err := install.managedRemovalIdentity(); err != nil { + return err + } + } + if _, err := install.planManaged(ctx); err != nil { + return err + } + // The first complete inspection above creates no files. Recheck under all + // three locks, retaining scheduling.lock through drain and daemon shutdown. + for _, directory := range []string{install.root, install.state, filepath.Join(install.state, "remote")} { + exists, err := updateExists(directory) + if err != nil { + return err + } + if exists { + if err := installInspectDirectory(directory); err != nil { + return err + } + } else if err := installDirectory(directory); err != nil { + return err + } + } + lock, err := installLock(install.state, "install.lock") + if err != nil { + return err + } + defer lock.Close() + scheduling, err := installLock(install.state, "scheduling.lock") + if err != nil { + return err + } + defer scheduling.Close() + remote, err := installLock(filepath.Join(install.state, "remote"), "control.lock") + if err != nil { + return err + } + defer remote.Close() + if err := install.validatePayload(); err != nil { + return err + } + if err := install.managedVerify(ctx, source); err != nil { + return err + } + wrappers, err := install.managedWrappers() + if err != nil { + return err + } + p, err := install.planManaged(ctx) + if err != nil { + return err + } + identity := install.identity + if install.unregisterServices { + identity, err = install.managedRemovalIdentity() + if err != nil { + return err + } + } + raw, err := json.MarshalIndent(identity, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + marker := filepath.Join(install.root, "install-in-progress.json") + if err := installAtomic(marker, raw, 0600); err != nil { + return err + } + if err := install.quiesceManaged(ctx, p); err != nil { + return err + } + if err := install.managedStopMenu(ctx, source); err != nil { + return err + } + for service := range p.statuses { + status, err := install.managedStatus(ctx, install.app, service) + if err != nil { + return err + } + if status != "notRegistered" { + return fmt.Errorf("Service %s is still registered; application must be preserved", service) + } + } + if !install.unregisterServices { + return nil + } + // Confirm every wrapper again before retiring any metadata. Keep private + // queue state, credentials, configuration, history and retained apps intact. + current, err := install.managedWrappers() + if err != nil { + return err + } + if !reflect.DeepEqual(wrappers, current) { + return errors.New("CLI wrappers changed during service removal; evidence preserved") + } + if err := install.managedLifecycleCustody(); err != nil { + return err + } + // Only candidate-only receipts can reach removal. Retire their private + // custody before pending state; never delete a retained application backup. + if err := install.managedCleanupBackup(); err != nil { + return err + } + if err := installAtomic(filepath.Join(install.root, "build-info.json"), raw, 0600); err != nil { + return err + } + for filename, expected := range wrappers { + if err := install.managedRemoveFile(filename, expected); err != nil { + return err + } + } + if p.previous != nil { + metadata, err := appservices.Load(install.home) + if err != nil { + return err + } + if !reflect.DeepEqual(metadata, p.previous) { + return errors.New("Managed installation custody changed during removal") + } + filename := filepath.Join(install.root, "installation.json") + expected, err := appservices.ReadFile(install.home, filename, 16384, false) + if err != nil { + return err + } + if err := install.managedRemoveFile(filename, expected); err != nil { + return err + } + } + return install.managedRemoveFile(marker, raw) +} + +func (install *installer) managedStopped(ctx context.Context) error { + for _, label := range []string{"io.went.agent-heavy", "io.went.agent-resources", "io.went.agent-remote", "io.went.agent-queue"} { + if _, loaded, err := install.inspectService(ctx, label); err != nil { + return err + } else if loaded { + return errors.New("Service is still loaded; application must be preserved") + } + } + for _, entry := range []struct{ directory, name string }{{install.state, "queue-daemon.lock"}, {install.state, "resource-controller.lock"}, {filepath.Join(install.state, "remote"), "supervisor.lock"}} { + busy, err := installInspectLock(entry.directory, entry.name) + if err != nil { + return err + } + if busy { + return errors.New("Service still holds its lifetime lock; application must be preserved") + } + } + if install.prepareReplacement || install.unregisterServices { + cleanup, err := install.disabledRemoteCleanup(ctx) + if err != nil { + return err + } + if cleanup { + return errors.New("Phone access process cleanup is incomplete; application must be preserved") + } + } + return nil +} + +// status --json is the public dashboard shape. The daemon's private snapshot +// uses tasks/groups and must not stand in for the real CLI readiness response. +func managedReadiness(raw []byte) error { + value, err := decodeObject(raw) + if err != nil { + return errors.New("Managed daemon returned invalid readiness JSON") + } + queue := obj(value["queue"]) + jobs, jobsOK := value["jobs"].([]any) + limit, limitOK := integer(queue["limit"]) + running, runningOK := integer(queue["running"]) + waiting, waitingOK := integer(queue["waiting"]) + _, timestampErr := time.Parse(time.RFC3339Nano, text(value["updatedAt"])) + if queue == nil || !jobsOK || jobs == nil || obj(value["scheduler"]) == nil || !limitOK || limit < 1 || limit > MaxParallel || !runningOK || running < 0 || !waitingOK || waiting < 0 || timestampErr != nil || !slices.Contains([]string{"running", "paused"}, text(queue["status"])) { + return errors.New("Managed daemon returned invalid readiness JSON") + } + return nil +} + +// quiesceManaged runs with install.lock, scheduling.lock and remote control.lock +// held. It never publishes a bundle, rewrites wrappers or registers services. +func (install *installer) quiesceManaged(ctx context.Context, p *managedPlan) error { + for _, service := range []string{"resources", "remote", "queue", "login"} { + if p.statuses[service] == "enabled" || install.unregisterServices && p.statuses[service] == "requiresApproval" { + if label := map[string]string{"queue": "io.went.agent-heavy", "resources": "io.went.agent-resources", "remote": "io.went.agent-remote"}[service]; label != "" { + output, loaded, err := install.inspectService(ctx, label) + if err != nil { + return err + } + if loaded { + if err := install.managedLoaded(output, label, install.app); err != nil { + return err + } + } + } + if err := install.managedAction(ctx, install.app, "unregister", service); err != nil { + return err + } + } + } + if err := install.stopManagedLegacy(ctx, p); err != nil { + return err + } + if exists, err := updateExists(QueueDatabasePath(install.state)); err != nil { + return err + } else if exists { + if err := install.managedOfflineDrain(ctx); err != nil { + return err + } + } + if p.pueue { + snapshot, err := install.legacySnapshot(ctx, p.node, false) + if err != nil { + return err + } + if err := installRequireDrained(obj(snapshot["tasks"])); err != nil { + return err + } + data, err := json.Marshal(snapshot) + if err != nil { + return err + } + if err := installAtomic(filepath.Join(install.state, "legacy-queue-import.json"), data, 0600); err != nil { + return err + } + } + if install.serviceSlot != "" { + data, _ := json.Marshal(Object{"slot": install.serviceSlot}) + filename := filepath.Join(install.state, "legacy-slot.json") + existing, err := appservices.ReadFile(install.home, filename, 16384, false) + if err == nil { + slot, err := installValidateSlot(existing) + if err != nil || slot != install.serviceSlot { + return errors.New("Retained queue lease setting changed; evidence preserved") + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } else if err := installAtomic(filename, data, 0600); err != nil { + return err + } + } + return install.managedStopped(ctx) +} + +func (install *installer) executeManaged(ctx context.Context) error { + if err := install.managedPublicationAccess(); err != nil { + return err + } + if err := install.recoverManagedPublication(ctx); err != nil { + return err + } + // All safety and drain checks precede private-directory/lock creation. + source := install.app + if !install.appBundle { + source = filepath.Join(install.payload, "Agent Queue.app") + } + if err := install.managedVerify(ctx, source); err != nil { + return err + } + if _, err := install.planManaged(ctx); err != nil { + if !errors.Is(err, errInstallLegacyPhoneAccess) || !install.disablePhoneAccess { + return err + } + // Explicit recovery remains ownership-checked and cannot precede drain. + if err := install.validateRetirement(); err != nil { + return err + } + node, err := installConfig(filepath.Join(install.config, "pueue.yml")) + if err != nil { + return err + } + if install.embeddedDefinition() { + snapshot, err := install.managedSnapshot(ctx) + if err != nil { + return err + } + if obj(snapshot["tasks"]) == nil { + return errors.New("Invalid queue snapshot during phone access recovery") + } + if err := installRequireDrained(obj(snapshot["tasks"])); err != nil { + return err + } + } else { + tasks, err := install.tasks(ctx, node, install.loaded(ctx, "io.went.agent-heavy")) + if err != nil { + return err + } + if err := installRequireDrained(tasks); err != nil { + return err + } + } + if err := install.requireLegacyCleanup(); err != nil { + return err + } + if err := install.validatePhoneAccessRecovery(ctx); err != nil { + return err + } + if _, err := install.run(ctx, filepath.Join(source, "Contents/MacOS/agent-remote"), "disable"); err != nil { + return err + } + if _, err := install.planManaged(ctx); err != nil { + return err + } + } + for _, directory := range []string{install.root, install.state, install.config, filepath.Join(install.home, ".local/bin"), filepath.Join(install.state, "remote")} { + if err := installDirectory(directory); err != nil { + return err + } + } + if !install.appBundle && filepath.Dir(install.app) != "/Applications" { + parent, err := integrationParent(install.app, true) + if err != nil { + return err + } + parent.Close() // Existing publication-directory modes are never changed. + } + lock, err := installLock(install.state, "install.lock") + if err != nil { + return err + } + defer lock.Close() + scheduling, err := installLock(install.state, "scheduling.lock") + if err != nil { + return err + } + defer scheduling.Close() + remote, err := installLock(filepath.Join(install.state, "remote"), "control.lock") + if err != nil { + return err + } + defer remote.Close() + if err := install.validatePayload(); err != nil { + return err + } + p, err := install.planManaged(ctx) + if err != nil { + return err + } + raw, err := json.MarshalIndent(install.identity, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + if err := installAtomic(filepath.Join(install.root, "install-in-progress.json"), raw, 0600); err != nil { + return err + } + if err := install.quiesceManaged(ctx, p); err != nil { + return err + } + if !install.appBundle { + if err := install.managedStopMenu(ctx, source); err != nil { + return err + } + } + expected := install.identity + if err := install.publishManaged(ctx); err != nil { + return err + } + published, err := managedCaptureBundle(install.app) + if err != nil { + return err + } + if !published.Build.Equal(expected) { + return errors.New("Published application differs from the pending candidate; activation refused") + } + if err := install.managedValidateExpected(expected); err != nil { + return err + } + if err := install.managedSupport(); err != nil { + return err + } + if p.node != nil { + current, err := installConfigCallback(p.node) + if err != nil { + return err + } + if !install.standardCleanupCallback(current) && current != installQuote(filepath.Join(install.home, ".local/bin/agent-heavy"))+" cleanup {{ id }}" { + return errors.New("Custom cleanup callback cannot be silently changed during managed migration") + } + callback := installYAMLValue(installYAMLValue(p.node, "daemon"), "callback") + if callback == nil { + return errors.New("Missing migration callback") + } + callback.Value = installQuote(filepath.Join(install.home, ".local/bin/agent-heavy")) + " cleanup {{ id }}" + data, err := yaml.Marshal(p.node) + if err != nil { + return err + } + if err := installAtomic(filepath.Join(install.config, "pueue.yml"), data, 0600); err != nil { + return err + } + } + metadata, err := json.MarshalIndent(appservices.Installation{SchemaVersion: 1, App: install.app, ServiceManagement: true}, "", " ") + if err != nil { + return err + } + if err := installAtomic(filepath.Join(install.root, "installation.json"), append(metadata, '\n'), 0600); err != nil { + return err + } + for _, service := range []string{"queue", "resources", "login"} { + if err := managedRequireBundle(install.app, published); err != nil { + return err + } + if err := install.managedAction(ctx, install.app, "register", service); err != nil { + return err + } + } + ready := false + for attempt := 0; attempt < 30; attempt++ { + output, err := install.run(ctx, filepath.Join(install.app, "Contents/MacOS/agent-heavy"), "status", "--json") + if err == nil { + if err := managedReadiness(output); err != nil { + return err + } + ready = true + break + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + } + } + if !ready { + return errors.New("The managed queue daemon did not become ready; setup remains incomplete") + } + if p.python { + if err := install.removeLegacy(); err != nil { + return err + } + } + if err := remote.Close(); err != nil { + return err + } + // The shared remote controller owns migration and preserves credentials/CA. + // Publish location before invoking it, but keep the authoritative pending marker. + if p.remote || p.remoteCleanup { + if _, err := install.run(ctx, filepath.Join(install.app, "Contents/MacOS/agent-remote"), "migrate"); err != nil { + return fmt.Errorf("Managed phone access migration is incomplete: %w", err) + } + } + // The remote controller re-reads the user's choice under control.lock. + // Retain that lock through final readback/publication so a stale planning + // snapshot cannot re-enable access after a concurrent explicit disable. + remoteCommit, err := installLock(filepath.Join(install.state, "remote"), "control.lock") + if err != nil { + return err + } + defer remoteCommit.Close() + remoteEnabled, err := install.remoteEnabled() + if err != nil { + return err + } + for _, service := range []string{"queue", "resources", "login"} { + status, err := install.managedStatus(ctx, install.app, service) + if err != nil { + return err + } + if status != "enabled" { + return fmt.Errorf("Managed service %s is %s; setup remains incomplete", service, status) + } + } + for _, label := range []string{"io.went.agent-heavy", "io.went.agent-resources"} { + output, loaded, err := install.inspectService(ctx, label) + if err != nil { + return err + } + if !loaded { + return fmt.Errorf("Managed service %s is not loaded; setup remains incomplete", label) + } + if err := install.managedLoaded(output, label, install.app); err != nil { + return err + } + } + remoteStatus, err := install.managedStatus(ctx, install.app, "remote") + if err != nil { + return err + } + if remoteEnabled && remoteStatus != "enabled" || !remoteEnabled && remoteStatus != "notRegistered" { + return errors.New("Phone access service does not match its retained enabled setting; setup remains incomplete") + } + if err := install.managedVerify(ctx, install.app); err != nil { + return err + } + if err := install.managedValidateExpected(expected); err != nil { + return err + } + if err := managedRequireBundle(install.app, published); err != nil { + return err + } + // Retire only the two canonical owned executable files, after the remote + // controller has adopted the bundle. Unknown private files remain untouched. + for _, name := range []string{"agent-heavy", "agent-remote"} { + filename := filepath.Join(install.root, "bin", name) + if _, err := appservices.ReadFile(install.home, filename, updateMaxArchive, false); errors.Is(err, os.ErrNotExist) { + continue + } else if err != nil { + return err + } + parent, err := integrationParent(filename, false) + if err != nil { + return err + } + err = unix.Unlinkat(int(parent.Fd()), name, 0) + parent.Close() + if err != nil { + return err + } + } + if err := installAtomic(filepath.Join(install.root, "build-info.json"), raw, 0600); err != nil { + return err + } + if err := install.managedCleanupBackup(); err != nil { + return err + } + if err := os.Remove(filepath.Join(install.root, "install-in-progress.json")); err != nil { + return err + } + root, err := os.Open(install.root) + if err != nil { + return err + } + err = root.Sync() + root.Close() + if err != nil { + return err + } + // mainApp registration only arranges subsequent logins. This one-shot, + // explicit installer launch runs after durable completion, never as an agent. + if !install.testing && !install.appBundle { + if _, err := install.run(ctx, "/usr/bin/open", "-a", install.app); err != nil { + return err + } + } + return nil +} + +// A crash can leave the last good app renamed but no candidate at the public +// path. Restore only that identity-checked owned backup, under installation and +// scheduling locks and after the same drained-queue gate used for replacement. +func (install *installer) recoverManagedPublication(ctx context.Context) error { + if install.appBundle { + return nil + } + if err := install.managedPublicationAccess(); err != nil { + return err + } + if exists, err := updateExists(install.app); err != nil || exists { + return err + } + previous := install.managedPreviousPath() + if exists, err := updateExists(previous); err != nil || !exists { + return err + } + lock, err := installLock(install.state, "install.lock") + if err != nil { + return err + } + defer lock.Close() + scheduling, err := installLock(install.state, "scheduling.lock") + if err != nil { + return err + } + defer scheduling.Close() + remote, err := installLock(filepath.Join(install.state, "remote"), "control.lock") + if err != nil { + return err + } + defer remote.Close() + if err := install.managedBackupAccess(); err != nil { + return err + } + receipt, raw, err := install.managedReadReceipt() + if err != nil { + return err + } + if receipt == nil || receipt.Backup == nil { + return errors.New("Retained application has no exact owned publication receipt; evidence preserved") + } + if err := installValidatePayloadTree(previous, false); err != nil { + return err + } + if _, err := install.run(ctx, "/usr/bin/codesign", "--verify", "--strict", "--verbose=2", previous); err != nil { + return fmt.Errorf("Retained application signature verification failed: %w", err) + } + p := &managedPlan{loaded: map[string]bool{}} + for _, label := range []string{"io.went.agent-heavy", "io.went.agent-resources", "io.went.agent-remote"} { + output, loaded, err := install.inspectService(ctx, label) + if err != nil { + return err + } + if loaded { + if err := install.managedLoaded(output, label, install.app); err != nil { + return err + } + } + p.loaded[label] = loaded + } + if err := install.managedDrain(ctx, p); err != nil { + return err + } + parent, err := install.managedPublicationParent(true) + if err != nil { + return err + } + defer parent.Close() + // Recheck after signature commands and drain inspection. Never overwrite a + // target that appeared while recovery was checking the retained evidence. + if err := install.managedBackupAccess(); err != nil { + return err + } + if exists, err := updateExists(install.app); err != nil { + return err + } else if exists { + return errors.New("Managed recovery target appeared during inspection; evidence preserved") + } + if err := managedRequireBundle(previous, *receipt.Backup); err != nil { + return err + } + if err := unix.Renameat(int(parent.Fd()), filepath.Base(previous), int(parent.Fd()), filepath.Base(install.app)); err != nil { + return err + } + if err := parent.Sync(); err != nil { + return err + } + if err := managedRequireBundle(install.app, *receipt.Backup); err != nil { + return err + } + return install.managedRemoveFile(install.managedReceiptPath(), raw) +} + +func (install *installer) preflightManaged(ctx context.Context, report installPreflightReport) installPreflightReport { + for _, directory := range []string{install.root, install.state, install.config, install.launch, filepath.Join(install.home, ".local/bin")} { + if err := installInspectDirectory(directory); err != nil { + report.add("private-path-unsafe", "Review private installation permissions", err.Error(), directory, "reveal") + return report + } + } + for _, entry := range []struct{ directory, name string }{{install.state, "install.lock"}, {install.state, "scheduling.lock"}, {filepath.Join(install.state, "remote"), "control.lock"}} { + busy, err := installInspectLock(entry.directory, entry.name) + if err != nil { + report.add("private-path-unsafe", "Review coordination", err.Error(), entry.directory, "reveal") + } else if busy { + report.add("installer-busy", "Another queue operation is running", "Wait for that operation to finish.", "", "wait") + } + } + source := install.app + if !install.appBundle { + source = filepath.Join(install.payload, "Agent Queue.app") + } + if err := install.managedVerify(ctx, source); err != nil { + report.add("payload-invalid", "Review the signed application package", err.Error(), source, "reveal") + return report + } + if _, err := install.planManaged(ctx); err != nil { + report.add("managed-setup-blocked", "Review managed service setup", err.Error(), install.app, "reveal", "wait") + } + return report +} diff --git a/internal/backend/install_managed_copy.go b/internal/backend/install_managed_copy.go new file mode 100644 index 0000000..2d58582 --- /dev/null +++ b/internal/backend/install_managed_copy.go @@ -0,0 +1,441 @@ +package backend + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +// Publication names require a protected parent. Holding a parent descriptor +// does not prevent other writers from rebinding its children. Private state +// continues to use integrationParent with its existing stricter rules. +func (install *installer) managedPublicationParent(require bool) (*os.File, error) { + if !integrationValidPath(install.app) || filepath.Clean(install.app) != install.app { + return nil, errors.New("Managed app publication requires a canonical absolute path") + } + if filepath.Dir(install.app) == "/Applications" { + root, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + defer unix.Close(root) + var rootInfo unix.Stat_t + if err := unix.Fstat(root, &rootInfo); err != nil { + return nil, err + } + if err := managedSystemPublicationParentInfo(&rootInfo); err != nil { + return nil, err + } + fd, err := unix.Openat(root, "Applications", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + parent := os.NewFile(uintptr(fd), "/Applications") + var info unix.Stat_t + if err := unix.Fstat(fd, &info); err != nil { + parent.Close() + return nil, err + } + if err := managedSystemPublicationParentInfo(&info); err != nil { + parent.Close() + return nil, err + } + return parent, nil + } + path := install.app + for { + parent, err := integrationParent(path, false) + if !errors.Is(err, os.ErrNotExist) || require { + if err != nil { + return nil, fmt.Errorf("Managed application publication requires a protected parent; use the explicit installer with per-user Applications, or an approved protected location for shared deployments: %w", err) + } + return parent, nil + } + // A first install may not yet have its private Applications directory. + // Validate the nearest existing ancestor without creating or chmodding it. + path = filepath.Dir(path) + } +} + +func managedSystemPublicationParentInfo(info *unix.Stat_t) error { + if uint32(info.Mode)&unix.S_IFMT != unix.S_IFDIR || info.Uid != 0 || uint32(info.Mode)&0022 != 0 { + return errors.New("Managed application publication requires a protected root-owned Applications directory without group/world write permission; use your per-user Applications directory with the explicit installer, or an approved protected location for shared deployments") + } + return nil +} + +func (install *installer) managedStagingAccess(publication *os.File) error { + private, err := integrationParent(filepath.Join(install.home, ".managed-app-probe"), false) + if err != nil { + return err + } + defer private.Close() + var left, right unix.Stat_t + if err := unix.Fstat(int(private.Fd()), &left); err != nil { + return err + } + if err := unix.Fstat(int(publication.Fd()), &right); err != nil { + return err + } + if uint32(right.Mode)&unix.S_IFMT != unix.S_IFDIR || (right.Uid != 0 && right.Uid != uint32(os.Getuid())) || uint32(right.Mode)&0022 != 0 { + return errors.New("Managed app publication has an unsafe parent") + } + if err := unix.Faccessat(int(publication.Fd()), ".", unix.W_OK|unix.X_OK, 0); err != nil { + return errors.New("Application directory is not writable by this account") + } + if left.Dev != right.Dev { + return errors.New("Private app staging and Applications must share a filesystem for atomic publication; no services were changed") + } + if err := unix.Access(install.home, unix.W_OK|unix.X_OK); err != nil { + return errors.New("Private app staging is not writable by this account; no services were changed") + } + return nil +} + +func managedCopyName(name string) bool { + return name != "" && name != "." && name != ".." && !filepath.IsAbs(name) && filepath.Base(name) == name +} + +func managedCopyInfo(info *unix.Stat_t, directory bool) error { + want := uint32(unix.S_IFREG) + if directory { + want = unix.S_IFDIR + } + if uint32(info.Mode)&unix.S_IFMT != want || (info.Uid != uint32(os.Getuid()) && info.Uid != 0) || uint32(info.Mode)&007022 != 0 || (!directory && info.Nlink != 1) { + return errors.New("Managed app has unsafe ownership, permissions, links, or file type") + } + return nil +} + +// Check the name as well as the held descriptor. This detects removal/rebinding +// of an entry in a shared parent without following the replacement. +func managedCopyNamed(parent *os.File, name string, file *os.File) error { + var named, held unix.Stat_t + if err := unix.Fstatat(int(parent.Fd()), name, &named, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return err + } + if err := unix.Fstat(int(file.Fd()), &held); err != nil { + return err + } + if named.Dev != held.Dev || named.Ino != held.Ino || named.Uid != uint32(os.Getuid()) || uint32(named.Mode)&unix.S_IFMT != unix.S_IFDIR || uint32(named.Mode)&0022 != 0 { + return errors.New("Managed app staging directory changed during publication") + } + return nil +} + +type managedCopyBudget struct { + entries int + bytes int64 +} + +// Copy only through already-open directory descriptors. Every destination entry +// is new; no source or destination symlink is followed, even after a rename of +// the containing path. The caller decides which parent is an approved boundary. +func managedCopyTreeAt(ctx context.Context, source string, parent *os.File, name string) error { + if !managedCopyName(name) { + return errors.New("Managed app staging must be a direct child of its checked parent") + } + input, err := installPayloadParent(source, filepath.Join(source, ".managed-copy-probe"), true) + if err != nil { + return err + } + defer input.Close() + if err := unix.Mkdirat(int(parent.Fd()), name, 0700); err != nil { + return err + } + fd, err := unix.Openat(int(parent.Fd()), name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return err + } + output := os.NewFile(uintptr(fd), name) + defer output.Close() + if err := managedCopyNamed(parent, name, output); err != nil { + return err + } + budget := &managedCopyBudget{} + if err := budget.directory(ctx, input, output, 0); err != nil { + return err + } + return managedCopyNamed(parent, name, output) +} + +// Exercise the writer's ownership, type and size policy before quiescence, +// without creating a directory or reading file contents. +func managedCopySourceAccess(source string) error { + input, err := installPayloadParent(source, filepath.Join(source, ".managed-copy-probe"), true) + if err != nil { + return err + } + defer input.Close() + return (&managedCopyBudget{}).directory(context.Background(), input, nil, 0) +} + +func (budget *managedCopyBudget) directory(ctx context.Context, input, output *os.File, depth int) error { + if err := ctx.Err(); err != nil { + return err + } + budget.entries++ + if budget.entries > 10000 || depth > 128 { + return errors.New("Managed app exceeds its entry or nesting limit") + } + var before unix.Stat_t + if err := unix.Fstat(int(input.Fd()), &before); err != nil { + return err + } + if err := managedCopyInfo(&before, true); err != nil { + return err + } + for { + entries, readErr := input.ReadDir(128) + if readErr != nil && !errors.Is(readErr, io.EOF) { + return readErr + } + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return err + } + name := entry.Name() + if !managedCopyName(name) { + return errors.New("Invalid managed app entry name") + } + fd, err := unix.Openat(int(input.Fd()), name, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) + if err != nil { + return err + } + child := os.NewFile(uintptr(fd), name) + err = budget.entry(ctx, child, output, name, depth+1) + child.Close() + if err != nil { + return err + } + } + if errors.Is(readErr, io.EOF) { + break + } + } + var after unix.Stat_t + if err := unix.Fstat(int(input.Fd()), &after); err != nil { + return err + } + if integrationStatIdentity(&before) != integrationStatIdentity(&after) { + return errors.New("Managed app source directory changed during staging") + } + if output != nil { + return output.Sync() + } + return nil +} + +func (budget *managedCopyBudget) entry(ctx context.Context, input, parent *os.File, name string, depth int) error { + var before unix.Stat_t + if err := unix.Fstat(int(input.Fd()), &before); err != nil { + return err + } + directory := uint32(before.Mode)&unix.S_IFMT == unix.S_IFDIR + if err := managedCopyInfo(&before, directory); err != nil { + return err + } + if directory { + if parent == nil { + return budget.directory(ctx, input, nil, depth) + } + if err := unix.Mkdirat(int(parent.Fd()), name, 0700); err != nil { + return err + } + fd, err := unix.Openat(int(parent.Fd()), name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return err + } + output := os.NewFile(uintptr(fd), name) + defer output.Close() + if err := managedCopyNamed(parent, name, output); err != nil { + return err + } + return budget.directory(ctx, input, output, depth) + } + budget.entries++ + if budget.entries > 10000 || before.Size < 0 || before.Size > updateMaxArchive || budget.bytes > updateMaxExpanded-before.Size { + return errors.New("Managed app exceeds its entry or content size limit") + } + budget.bytes += before.Size + if parent == nil { + return nil + } + mode := uint32(0600) + if uint32(before.Mode)&0111 != 0 { + mode = 0700 + } + fd, err := unix.Openat(int(parent.Fd()), name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, mode) + if err != nil { + return err + } + output := os.NewFile(uintptr(fd), name) + defer output.Close() + n, err := io.Copy(output, io.LimitReader(input, before.Size+1)) + if err != nil { + return err + } + var after unix.Stat_t + if err := unix.Fstat(int(input.Fd()), &after); err != nil { + return err + } + if n != before.Size || integrationStatIdentity(&before) != integrationStatIdentity(&after) { + return errors.New("Managed app source file changed during staging") + } + if err := ctx.Err(); err != nil { + return err + } + return output.Sync() +} + +func (install *installer) managedCopyApp(ctx context.Context, source, destination string) error { + if filepath.Dir(install.app) != filepath.Dir(destination) { + return errors.New("Managed app staging escapes its publication parent") + } + parent, err := install.managedPublicationParent(true) + if err != nil { + return err + } + defer parent.Close() + return install.managedCopyAppAt(ctx, source, parent, filepath.Base(destination)) +} + +// ditto needs pathname arguments on macOS. Run it only inside a checked private +// home workspace, never against a pathname below the group-writable parent. +// After native metadata and signature verification, renameat publishes the +// complete bundle into a protected parent on the same filesystem. +func (install *installer) managedCopyAppAt(ctx context.Context, source string, publication *os.File, name string) (result error) { + if !managedCopyName(name) { + return errors.New("Invalid managed app staging name") + } + if err := install.managedStagingAccess(publication); err != nil { + return err + } + expected, err := managedCaptureBundle(source) + if err != nil { + return err + } + private, err := integrationParent(filepath.Join(install.home, ".managed-app-probe"), false) + if err != nil { + return err + } + defer private.Close() + workspaceName := ".AgentQueue-staging-" + token() + if err := unix.Mkdirat(int(private.Fd()), workspaceName, 0700); err != nil { + return err + } + fd, err := unix.Openat(int(private.Fd()), workspaceName, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return err + } + workspace := os.NewFile(uintptr(fd), workspaceName) + defer workspace.Close() + path := filepath.Join(install.home, workspaceName) + var candidateRoot *os.File + var ownedCandidate string + exported := false + defer func() { + if candidateRoot != nil { + defer candidateRoot.Close() + } + + if err := managedCopyNamed(private, workspaceName, workspace); err != nil { + result = errors.Join(result, fmt.Errorf("Private staging custody changed; evidence preserved: %w", err)) + return + } + if candidateRoot != nil && !exported { + if err := managedCopyNamed(workspace, ownedCandidate, candidateRoot); err != nil { + result = errors.Join(result, fmt.Errorf("Private candidate custody changed; evidence preserved: %w", err)) + return + } + } + result = errors.Join(result, os.RemoveAll(path)) + }() + if err := managedCopyNamed(private, workspaceName, workspace); err != nil { + return err + } + if err := managedCopyTreeAt(ctx, source, workspace, "validated.app"); err != nil { + return err + } + candidate := "validated.app" + if !install.testing { + if err := os.RemoveAll(filepath.Join(path, candidate)); err != nil { + return err + } + candidate = ".Agent Queue.installing-" + token() + ".app" + if _, err := install.run(ctx, "/usr/bin/ditto", "--rsrc", "--extattr", "--qtn", "--acl", source, filepath.Join(path, candidate)); err != nil { + return err + } + } + // The test copier follows the same verification sequence as native ditto. + // Keep the diagnostic candidate basename in the private workspace as well. + if install.testing { + verifiedName := ".Agent Queue.installing-" + token() + ".app" + if err := unix.Renameat(int(workspace.Fd()), candidate, int(workspace.Fd()), verifiedName); err != nil { + return err + } + candidate = verifiedName + } + candidatePath := filepath.Join(path, candidate) + candidateRoot, err = installPayloadParent(candidatePath, filepath.Join(candidatePath, ".custody-probe"), true) + if err != nil { + return err + } + ownedCandidate = candidate + before, err := managedCaptureBundle(candidatePath) + if err != nil { + return err + } + if before.Digest != expected.Digest || !before.Build.Equal(expected.Build) { + return errors.New("Managed staged candidate differs from the expected source identity") + } + if err := install.managedVerify(ctx, candidatePath); err != nil { + return err + } + if err := managedRequireBundle(candidatePath, before); err != nil { + return err + } + if err := managedRequireBundle(source, expected); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if err := managedCopyNamed(private, workspaceName, workspace); err != nil { + return err + } + if err := install.managedStagingAccess(publication); err != nil { + return err + } + var info unix.Stat_t + if err := unix.Fstatat(int(publication.Fd()), name, &info, unix.AT_SYMLINK_NOFOLLOW); !errors.Is(err, unix.ENOENT) { + if err != nil { + return err + } + return errors.New("Managed app staging destination already exists") + } + if err := unix.Renameat(int(workspace.Fd()), candidate, int(publication.Fd()), name); err != nil { + return fmt.Errorf("Publish managed app staging: %w", err) + } + exported = true + if err := publication.Sync(); err != nil { + // The caller has not received the stage yet. Retire only this exact + // verified root if a durability failure prevents that handoff. + if custodyErr := managedCopyNamed(publication, name, candidateRoot); custodyErr != nil { + return errors.Join(err, custodyErr) + } + root, openErr := os.OpenRoot(publication.Name()) + if openErr != nil { + return errors.Join(err, openErr) + } + cleanupErr := root.RemoveAll(name) + root.Close() + return errors.Join(err, cleanupErr) + } + return nil +} diff --git a/internal/backend/install_managed_custody.go b/internal/backend/install_managed_custody.go new file mode 100644 index 0000000..80ee1d7 --- /dev/null +++ b/internal/backend/install_managed_custody.go @@ -0,0 +1,297 @@ +package backend + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/Dunky13/agent-job-queue/internal/appservices" + "golang.org/x/sys/unix" + "howett.net/plist" +) + +// A filename, a signature, or an open parent alone does not establish custody. +// Record both the exact filesystem object and its verified content identity in +// private installation state before moving the last good application. +type managedBundleIdentity struct { + Device uint64 `json:"device"` + Inode uint64 `json:"inode"` + Digest [32]byte `json:"digest"` + Build BuildInfo `json:"build"` +} + +type managedPublicationReceipt struct { + SchemaVersion int `json:"schemaVersion"` + App string `json:"app"` + Pending BuildInfo `json:"pending"` + Candidate managedBundleIdentity `json:"candidate"` + Backup *managedBundleIdentity `json:"backup"` +} + +func managedCaptureBundle(app string) (managedBundleIdentity, error) { + var result managedBundleIdentity + parent, err := installPayloadParent(app, filepath.Join(app, ".custody-probe"), true) + if err != nil { + return result, err + } + defer parent.Close() + var before, after, named unix.Stat_t + if err := unix.Fstat(int(parent.Fd()), &before); err != nil { + return result, err + } + if err := installValidatePayloadTree(app, true); err != nil { + return result, err + } + result.Device, result.Inode = uint64(before.Dev), before.Ino + result.Digest, err = managedBundleDigest(app) + if err != nil { + return result, err + } + raw, err := installReadPayload(app, filepath.Join(app, "Contents/Info.plist"), 16384, true) + if err != nil { + return result, err + } + var info Object + if _, err := plist.Unmarshal(raw, &info); err != nil { + return result, err + } + files := []string{"Contents/Resources/build-info.json"} + if info["AgentQueueServiceManagement"] == true { + files = append(files, "Contents/Resources/payload/source/build-info.json") + } + for index, name := range files { + raw, err := installReadPayload(app, filepath.Join(app, name), 16384, true) + if err != nil { + return result, err + } + build, err := ValidateBuildInfo(raw) + if err != nil { + return result, err + } + if index == 0 { + result.Build = build + } else if !result.Build.Equal(build) { + return result, errors.New("Managed candidate and embedded support identities differ") + } + } + build := "0" + if result.Build.Build != nil { + build = fmt.Sprint(*result.Build.Build) + } + if info["CFBundleIdentifier"] != "io.went.agent-queue" || info["CFBundleVersion"] != build || info["CFBundleShortVersionString"] != result.Build.Version { + return result, errors.New("Managed candidate metadata differs from its expected build identity") + } + if err := unix.Fstat(int(parent.Fd()), &after); err != nil { + return result, err + } + if err := unix.Lstat(app, &named); err != nil { + return result, err + } + if integrationStatIdentity(&before) != integrationStatIdentity(&after) || named.Dev != before.Dev || named.Ino != before.Ino { + return result, errors.New("Managed application changed while recording custody") + } + return result, nil +} + +func (identity managedBundleIdentity) equal(other managedBundleIdentity) bool { + return identity.Device == other.Device && identity.Inode == other.Inode && identity.Digest == other.Digest && identity.Build.Equal(other.Build) +} + +func managedRequireBundle(app string, expected managedBundleIdentity) error { + current, err := managedCaptureBundle(app) + if err != nil { + return err + } + if !expected.equal(current) { + return errors.New("Managed application custody changed; substituted entries and recovery evidence preserved") + } + return nil +} + +func (install *installer) managedReceiptPath() string { + return filepath.Join(install.root, "publication-custody.json") +} + +func (install *installer) managedPreviousPath() string { + return filepath.Join(filepath.Dir(install.app), ".Agent Queue.previous.app") +} + +func (install *installer) managedReadReceipt() (*managedPublicationReceipt, []byte, error) { + raw, err := appservices.ReadFile(install.home, install.managedReceiptPath(), 16384, false) + if errors.Is(err, os.ErrNotExist) { + return nil, nil, nil + } + if err != nil { + return nil, nil, err + } + fields, err := metadataFields(raw, "schemaVersion", "app", "pending", "candidate", "backup") + if err != nil { + return nil, nil, err + } + if _, err := ValidateBuildInfo(fields["pending"]); err != nil { + return nil, nil, err + } + for _, name := range []string{"candidate", "backup"} { + if name == "backup" && string(fields[name]) == "null" { + continue + } + bundle, err := metadataFields(fields[name], "device", "inode", "digest", "build") + if err != nil { + return nil, nil, err + } + if _, err := ValidateBuildInfo(bundle["build"]); err != nil { + return nil, nil, err + } + } + var receipt managedPublicationReceipt + if err := json.Unmarshal(raw, &receipt); err != nil { + return nil, nil, err + } + if receipt.SchemaVersion != 1 || receipt.App != install.app || receipt.Candidate.Inode == 0 || receipt.Candidate.Digest == [32]byte{} || receipt.Backup != nil && (receipt.Backup.Inode == 0 || receipt.Backup.Digest == [32]byte{}) { + return nil, nil, errors.New("Retained publication receipt belongs to another application; evidence preserved") + } + pending, err := installReadInfo(filepath.Join(install.root, "install-in-progress.json")) + if err != nil || !pending.Equal(receipt.Pending) || !receipt.Pending.Equal(receipt.Candidate.Build) { + return nil, nil, errors.New("Retained publication receipt differs from the pending installation; evidence preserved") + } + return &receipt, raw, nil +} + +// This read-only check precedes all quiescence, including identical reinstall. +// A pre-existing backup without an exact private receipt is never adopted. +func (install *installer) managedBackupAccess() error { + receipt, _, err := install.managedReadReceipt() + if err != nil { + return err + } + exists, err := updateExists(install.managedPreviousPath()) + if err != nil { + return err + } + if exists { + if receipt == nil || receipt.Backup == nil { + return errors.New("A previous application has no owned publication receipt; preserve it and resolve the retained backup before retrying setup") + } + if err := managedRequireBundle(install.managedPreviousPath(), *receipt.Backup); err != nil { + return err + } + } + if receipt == nil { + return nil + } + present, err := updateExists(install.app) + if err != nil { + return err + } + if !present { + if exists { + return nil // Recovery checks the same receipt under locks before rename. + } + return errors.New("Publication receipt has neither its candidate nor its backup; evidence preserved") + } + current, err := managedCaptureBundle(install.app) + if err != nil { + return err + } + if receipt.Candidate.equal(current) || !exists && receipt.Backup != nil && receipt.Backup.equal(current) { + return nil // The latter is an interruption before the old app was moved. + } + return errors.New("Installed application differs from retained publication custody; evidence preserved") +} + +// Preparation authorizes an external bundle move, which would invalidate an +// exact candidate receipt. Removal may retire candidate-only custody, but must +// preserve a retained last-good app until its pending publication is completed. +func (install *installer) managedLifecycleCustody() error { + if !install.prepareReplacement && !install.unregisterServices { + return nil + } + if err := install.managedBackupAccess(); err != nil { + return err + } + receipt, _, err := install.managedReadReceipt() + if err != nil || receipt == nil { + return err + } + if install.prepareReplacement || receipt.Backup != nil { + return errors.New("Complete the exact pending publication through explicit setup before preparing replacement or removing an app with a retained backup; preserve its receipt and application bundles") + } + return nil +} + +func (install *installer) managedValidateExpected(expected BuildInfo) error { + // validatePayload rechecks source, pending and target build ordering. Never + // allow its identity refresh to replace the already selected candidate. + err := install.validatePayload() + current := install.identity + install.identity = expected + if err != nil { + return err + } + if !current.Equal(expected) { + return errors.New("Managed installer source changed from the expected pending build") + } + return nil +} + +func (install *installer) managedCheckPublicationParent(parent *os.File) error { + current, err := install.managedPublicationParent(true) + if err != nil { + return err + } + defer current.Close() + var named, held unix.Stat_t + if err := unix.Fstat(int(current.Fd()), &named); err != nil { + return err + } + if err := unix.Fstat(int(parent.Fd()), &held); err != nil { + return err + } + if named.Dev != held.Dev || named.Ino != held.Ino { + return errors.New("Managed publication parent changed; evidence preserved") + } + return nil +} + +func (install *installer) managedCleanupBackup() error { + if err := install.managedBackupAccess(); err != nil { + return err + } + receipt, raw, err := install.managedReadReceipt() + if err != nil || receipt == nil { + return err + } + if err := managedRequireBundle(install.app, receipt.Candidate); err != nil { + return err + } + parent, err := install.managedPublicationParent(true) + if err != nil { + return err + } + defer parent.Close() + if exists, err := updateExists(install.managedPreviousPath()); err != nil { + return err + } else if exists { + if receipt.Backup == nil { + return errors.New("Retained backup lacks owned custody; evidence preserved") + } + if err := managedRequireBundle(install.managedPreviousPath(), *receipt.Backup); err != nil { + return err + } + root, err := os.OpenRoot(filepath.Dir(install.app)) + if err != nil { + return err + } + err = root.RemoveAll(filepath.Base(install.managedPreviousPath())) + root.Close() + if err != nil { + return err + } + if err := parent.Sync(); err != nil { + return err + } + } + return install.managedRemoveFile(install.managedReceiptPath(), raw) +} diff --git a/internal/backend/install_managed_custody_test.go b/internal/backend/install_managed_custody_test.go new file mode 100644 index 0000000..8c1c360 --- /dev/null +++ b/internal/backend/install_managed_custody_test.go @@ -0,0 +1,288 @@ +package backend + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func TestManagedSystemPublicationParentRequiresProtection(t *testing.T) { + for _, info := range []unix.Stat_t{ + {Mode: unix.S_IFDIR | 0755, Uid: 0}, + {Mode: unix.S_IFDIR | 0500, Uid: 0}, + {Mode: unix.S_IFDIR | 0775, Uid: 0}, + {Mode: unix.S_IFDIR | 0777, Uid: 0}, + {Mode: unix.S_IFDIR | 0757, Uid: 0}, + } { + err := managedSystemPublicationParentInfo(&info) + if (err == nil) != (uint32(info.Mode)&0022 == 0) { + t.Fatalf("mode %o: %v", info.Mode, err) + } + if err != nil && (!strings.Contains(err.Error(), "per-user Applications") || !strings.Contains(err.Error(), "approved protected location")) { + t.Fatal("namespace error is not actionable", err) + } + } +} + +func TestManagedWritablePublicationParentRefusedBeforeQuiescence(t *testing.T) { + for _, bundle := range []bool{false, true} { + t.Run(map[bool]string{false: "payload", true: "in-place"}[bundle], func(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + if err := m.run(t); err != nil { + t.Fatal(err) + } + if bundle { + i.appBundle, i.payload = true, filepath.Join(i.app, "Contents/Resources/payload") + } + parent := filepath.Dir(i.app) + if err := os.Chmod(parent, 0775); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(parent, 0700) }) + before := installTreeWitness(t, i.home) + m.calls = nil + if err := m.run(t); err == nil { + t.Fatal("mutable publication parent was accepted") + } + managedRequireNoServiceMutation(t, m.calls) + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("namespace refusal mutated installation") + } + }) + } +} + +func TestManagedUnownedBackupPreservedBeforeIdenticalReinstall(t *testing.T) { + for _, same := range []bool{false, true} { + t.Run(map[bool]string{false: "replacement", true: "identical"}[same], func(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + if err := m.run(t); err != nil { + t.Fatal(err) + } + if err := installCopyTree(i.app, i.managedPreviousPath()); err != nil { + t.Fatal(err) + } + if !same { + managedCandidate(t, m, "124") + } + before := installTreeWitness(t, i.home) + m.calls = nil + if err := m.run(t); err == nil || !strings.Contains(err.Error(), "receipt") { + t.Fatal("unowned backup accepted", err) + } + managedRequireNoServiceMutation(t, m.calls) + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("unowned backup refusal changed installation") + } + }) + } +} + +func managedRetainedBackupFixture(t *testing.T) *managedFixture { + t.Helper() + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + managedCandidate(t, m, "124") + m.nativeFailure = "register resources" + if err := m.run(t); err == nil { + t.Fatal("expected partially published installation") + } + m.nativeFailure = "" + return m +} + +func TestManagedBackupSubstitutionRefusedOnRetryRecoveryAndCleanup(t *testing.T) { + for _, operation := range []string{"retry", "recovery", "cleanup"} { + for _, substitution := range []string{"same-bytes-new-inode", "changed-content", "symlink"} { + t.Run(operation+"/"+substitution, func(t *testing.T) { + m := managedRetainedBackupFixture(t) + i := m.installer + previous := i.managedPreviousPath() + if operation == "recovery" { + if err := os.Rename(i.app, i.app+".pending"); err != nil { + t.Fatal(err) + } + } + switch substitution { + case "same-bytes-new-inode", "symlink": + if err := os.Rename(previous, previous+".original"); err != nil { + t.Fatal(err) + } + if substitution == "same-bytes-new-inode" { + if err := installCopyTree(previous+".original", previous); err != nil { + t.Fatal(err) + } + } else if err := os.Symlink(previous+".original", previous); err != nil { + t.Fatal(err) + } + case "changed-content": + integrationTestWrite(t, filepath.Join(previous, "Contents/Resources/unrelated"), []byte("preserve this")) + } + before := installTreeWitness(t, i.home) + m.calls = nil + var err error + switch operation { + case "retry": + err = m.run(t) + case "recovery": + err = i.recoverManagedPublication(context.Background()) + case "cleanup": + err = i.managedCleanupBackup() + } + if err == nil { + t.Fatal("substituted backup accepted") + } + managedRequireNoServiceMutation(t, m.calls) + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("substitution refusal changed retained evidence") + } + }) + } + } +} + +func TestManagedPrivateCandidateFailureCleanupAndSubstitution(t *testing.T) { + for _, failure := range []string{"signature", "changed-content", "substituted-root", "cancelled", "newer-target"} { + t.Run(failure, func(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + if err := m.run(t); err != nil { + t.Fatal(err) + } + managedCandidate(t, m, "124") + original := i.command + mutated := false + var staged string + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + i.command = func(ctx context.Context, args []string) ([]byte, error) { + last := args[len(args)-1] + if !mutated && args[0] == "/usr/bin/codesign" && strings.Contains(last, ".Agent Queue.installing-") && strings.HasSuffix(last, ".app") { + mutated, staged = true, last + if !strings.HasPrefix(last, i.home+"/.AgentQueue-staging-") { + t.Fatal("candidate signature verification escaped private staging", last) + } + switch failure { + case "signature": + return nil, errors.New("signature fixture refusal") + case "changed-content": + integrationTestWrite(t, filepath.Join(last, "Contents/Resources/substituted"), []byte("unexpected candidate")) + case "substituted-root": + if err := os.Rename(last, last+".original"); err != nil { + t.Fatal(err) + } + if err := installCopyTree(last+".original", last); err != nil { + t.Fatal(err) + } + case "cancelled": + cancel() + case "newer-target": + integrationTestWrite(t, filepath.Join(i.app, "Contents/Resources/build-info.json"), mustMetadataJSON(t, metadataTestInfo(t, "125"))) + } + } + return original(ctx, args) + } + if err := i.execute(ctx); err == nil || !mutated { + t.Fatal("missing staged failure", err, mutated) + } + workspaces, err := filepath.Glob(filepath.Join(i.home, ".AgentQueue-staging-*")) + if err != nil { + t.Fatal(err) + } + if failure == "substituted-root" { + if len(workspaces) != 1 { + t.Fatal("substituted candidate evidence was deleted", workspaces) + } + if _, err := os.Stat(staged); err != nil { + t.Fatal("substituted candidate was not preserved", err) + } + } else if len(workspaces) != 0 { + t.Fatal("owned failed staging leaked", workspaces) + } + publicStages, err := filepath.Glob(filepath.Join(filepath.Dir(i.app), ".Agent Queue.installing-*.app")) + if err != nil || len(publicStages) != 0 { + t.Fatal("owned public stage leaked", publicStages, err) + } + }) + } +} + +func TestManagedRecoveryRechecksBackupAfterSignatureAndTargetBeforeRename(t *testing.T) { + for _, substitution := range []string{"backup", "target"} { + t.Run(substitution, func(t *testing.T) { + m := managedRetainedBackupFixture(t) + i := m.installer + if err := os.Rename(i.app, i.app+".pending"); err != nil { + t.Fatal(err) + } + previous := i.managedPreviousPath() + mutated := false + original := i.command + i.command = func(ctx context.Context, args []string) ([]byte, error) { + if !mutated && args[0] == "/usr/bin/codesign" && args[len(args)-1] == previous { + mutated = true + if substitution == "backup" { + if err := os.Rename(previous, previous+".original"); err != nil { + t.Fatal(err) + } + if err := installCopyTree(previous+".original", previous); err != nil { + t.Fatal(err) + } + } else if err := installCopyTree(i.app+".pending", i.app); err != nil { + t.Fatal(err) + } + } + return original(ctx, args) + } + if err := i.recoverManagedPublication(context.Background()); err == nil || !mutated { + t.Fatal("recovery accepted a substituted name during signature verification", err) + } + if _, err := os.Lstat(previous); err != nil { + t.Fatal("recovery removed the retained backup", err) + } + if _, err := os.Lstat(i.managedReceiptPath()); err != nil { + t.Fatal("recovery removed custody evidence", err) + } + if substitution == "target" { + got, err := managedCaptureBundle(i.app) + if err != nil || got.Build.Build == nil || *got.Build.Build != 124 { + t.Fatal("recovery overwrote a concurrently appearing target", got, err) + } + } + }) + } +} + +func TestManagedOwnedBackupRecoveryRestoresThenRetriesExactCandidate(t *testing.T) { + m := managedRetainedBackupFixture(t) + i := m.installer + if err := os.Rename(i.app, i.app+".pending"); err != nil { + t.Fatal(err) + } + m.loaded = map[string]bool{} + for _, name := range []string{"queue", "resources", "login"} { + m.statuses[name] = "notRegistered" + } + if err := m.run(t); err != nil { + t.Fatal("receipt-bound interrupted publication could not recover", err) + } + actual, err := managedCaptureBundle(i.app) + if err != nil || actual.Build.Build == nil || *actual.Build.Build != 124 { + t.Fatal("recovery did not finish the expected pending candidate", actual, err) + } + for _, filename := range []string{i.managedPreviousPath(), i.managedReceiptPath(), filepath.Join(i.root, "install-in-progress.json")} { + if _, err := os.Lstat(filename); !errors.Is(err, os.ErrNotExist) { + t.Fatal("successful recovery retained owned publication state", filename, err) + } + } +} diff --git a/internal/backend/install_managed_lifecycle_test.go b/internal/backend/install_managed_lifecycle_test.go new file mode 100644 index 0000000..43db114 --- /dev/null +++ b/internal/backend/install_managed_lifecycle_test.go @@ -0,0 +1,636 @@ +package backend + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" +) + +func managedCandidate(t *testing.T, m *managedFixture, build string) BuildInfo { + t.Helper() + i := m.installer + identity := metadataTestInfo(t, build) + app := filepath.Join(i.payload, "Agent Queue.app") + for _, filename := range []string{filepath.Join(i.payload, "source/build-info.json"), filepath.Join(app, "Contents/Resources/build-info.json"), filepath.Join(app, "Contents/Resources/payload/source/build-info.json")} { + integrationTestWrite(t, filename, mustMetadataJSON(t, identity)) + } + info, err := updatePlist(filepath.Join(app, "Contents/Info.plist")) + if err != nil { + t.Fatal(err) + } + info["CFBundleVersion"] = build + info["CFBundleShortVersionString"] = identity.Version + if err := i.writePlist(filepath.Join(app, "Contents/Info.plist"), info); err != nil { + t.Fatal(err) + } + return identity +} + +func managedRemovalTarget(i *installer) { + i.prepareReplacement = false + i.unregisterServices = true + i.appBundle = true + i.payload = filepath.Join(i.app, "Contents/Resources/payload") +} + +func managedRequireNoServiceMutation(t *testing.T, calls [][]string) { + t.Helper() + for _, args := range calls { + if len(args) > 2 && args[1] == "--service-management" && args[2] != "status" || len(args) > 1 && args[0] == "launchctl" && args[1] != "print" { + t.Fatal("unexpected service mutation", args) + } + } +} + +func TestManagedAbsentLegacyMenuRequiresExactLaunchctlEvidence(t *testing.T) { + label := "io.went.agent-queue" + output := fmt.Sprintf("Bad request.\nCould not find service %q in domain for user gui: %d\n", label, os.Getuid()) + if !installServiceAbsent([]byte(output), 113, label) { + t.Fatal("canonical absent legacy menu was not recognized") + } + for _, witness := range []struct { + output string + code int + label string + }{ + {output, 1, label}, + {output + "unexpected failure", 113, label}, + {strings.Replace(output, "gui:", "system:", 1), 113, label}, + {output, 113, "foreign.service"}, + {strings.ReplaceAll(output, label, "foreign.service"), 113, "foreign.service"}, + } { + if installServiceAbsent([]byte(witness.output), witness.code, witness.label) { + t.Fatal("ambiguous service absence accepted", witness) + } + } +} + +func TestManagedLifecycleOptionParsing(t *testing.T) { + for _, args := range [][]string{{"--payload", "/private/payload", "--prepare-replacement"}, {"--app-bundle", "/Applications/Agent Queue.app", "--unregister-services"}} { + options, err := installParseOptions(args) + if err != nil || options.prepareReplacement == options.unregisterServices || options.preflight || options.recovery != "" { + t.Fatalf("valid lifecycle request %v: %+v %v", args, options, err) + } + for _, extra := range [][]string{{"--preflight", "--json"}, {"--preflight"}, {"--json"}, {"--recover", "disable-phone-access"}, {"--disable-phone-access"}, {"--prepare-replacement"}, {"--unregister-services"}, {"--job-id", "1"}, {"--expected-revision", strings.Repeat("a", 64)}} { + if _, err := installParseOptions(append(slices.Clone(args), extra...)); err == nil { + t.Fatal("invalid lifecycle combination accepted", args, extra) + } + } + } + for _, args := range [][]string{ + {"--app-bundle", "/Applications/Agent Queue.app", "--prepare-replacement"}, + {"--payload", "/private/payload", "--unregister-services"}, + {"--payload", "relative", "--prepare-replacement"}, + {"--payload", "/private/../payload", "--prepare-replacement"}, + {"--app-bundle", "relative.app", "--unregister-services"}, + {"--app-bundle", "/private/Agent Queue.app", "--unregister-services"}, + {"--app-bundle", "/Applications/not-an-app", "--unregister-services"}, + } { + if _, err := installParseOptions(args); err == nil { + t.Fatal("invalid target accepted", args) + } + } +} + +func TestManagedLifecycleRejectsUnmarkedLegacyCandidates(t *testing.T) { + for _, removal := range []bool{false, true} { + t.Run(map[bool]string{false: "prepare", true: "remove"}[removal], func(t *testing.T) { + f := installTestFixture(t) + i := f.installer + i.prepareReplacement = true + if removal { + if err := installCopyTree(filepath.Join(i.payload, "Agent Queue.app"), i.app); err != nil { + t.Fatal(err) + } + if err := installCopyTree(filepath.Join(i.payload, "source"), filepath.Join(i.app, "Contents/Resources/payload/source")); err != nil { + t.Fatal(err) + } + managedRemovalTarget(i) + } + before := installTreeWitness(t, i.home) + if err := f.run(t); err == nil || !strings.Contains(err.Error(), "marked") { + t.Fatal("unmarked lifecycle request accepted", err) + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("unmarked lifecycle request changed home") + } + managedRequireNoServiceMutation(t, f.calls) + }) + } +} + +func TestManagedLifecycleDrainRefusalChangesNothing(t *testing.T) { + for _, removal := range []bool{false, true} { + t.Run(map[bool]string{false: "prepare", true: "remove"}[removal], func(t *testing.T) { + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + i := m.installer + i.prepareReplacement = true + if removal { + managedRemovalTarget(i) + } + m.tasks["41"] = Object{"id": 41, "status": Object{"Running": Object{}}} + before := installTreeWitness(t, i.home) + m.calls = nil + if err := m.run(t); err == nil || !strings.Contains(err.Error(), "drained") { + t.Fatal("missing active-job refusal", err) + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("active-job refusal modified home") + } + managedRequireNoServiceMutation(t, m.calls) + }) + } +} + +func TestManagedPreparationFreezesBeforeAppMoveAndPostflightRetries(t *testing.T) { + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + i := m.installer + originalBundle := installBundleSnapshot(t, i.app) + originalWrappers, err := i.managedWrappers() + if err != nil { + t.Fatal(err) + } + candidate := managedCandidate(t, m, "124") + i.prepareReplacement = true + original := i.command + i.command = func(ctx context.Context, args []string) ([]byte, error) { + if len(args) == 4 && args[1] == "--service-management" && args[2] == "unregister" { + for _, entry := range []struct{ directory, name string }{{i.state, "install.lock"}, {i.state, "scheduling.lock"}, {filepath.Join(i.state, "remote"), "control.lock"}} { + busy, err := installInspectLock(entry.directory, entry.name) + if err != nil || !busy { + t.Fatalf("shutdown lacks %s: busy=%t %v", entry.name, busy, err) + } + } + pending, err := installReadInfo(filepath.Join(i.root, "install-in-progress.json")) + if err != nil || !pending.Equal(candidate) { + t.Fatal("shutdown lost candidate custody", pending, err) + } + } + return original(ctx, args) + } + m.calls = nil + if err := m.run(t); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(originalBundle, installBundleSnapshot(t, i.app)) { + t.Fatal("preparation changed signed app bytes") + } + wrappers, err := i.managedWrappers() + if err != nil || !reflect.DeepEqual(wrappers, originalWrappers) { + t.Fatal("preparation rewrote wrappers", err) + } + for _, status := range m.statuses { + if status != "notRegistered" { + t.Fatal("preparation retained registration", m.statuses) + } + } + completed, err := installReadInfo(filepath.Join(i.root, "build-info.json")) + if err != nil || completed.Build == nil || *completed.Build != 123 { + t.Fatal("preparation published completed identity", completed, err) + } + if err := m.run(t); err != nil { + t.Fatal("preparation retry", err) + } + // Simulate only the cask artifact move after successful preparation. + if err := os.RemoveAll(i.app); err != nil { + t.Fatal(err) + } + if err := installCopyTree(filepath.Join(i.payload, "Agent Queue.app"), i.app); err != nil { + t.Fatal(err) + } + i.prepareReplacement = false + i.appBundle = true + i.payload = filepath.Join(i.app, "Contents/Resources/payload") + m.nativeFailure = "register resources" + if err := m.run(t); err == nil { + t.Fatal("missing postflight partial failure") + } + m.nativeFailure = "" + if err := m.run(t); err != nil { + t.Fatal("postflight retry", err) + } + completed, err = installReadInfo(filepath.Join(i.root, "build-info.json")) + if err != nil || !completed.Equal(candidate) { + t.Fatal("postflight completed wrong identity", completed, err) + } + if _, err := os.Lstat(filepath.Join(i.root, "install-in-progress.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("postflight kept pending marker", err) + } +} + +func TestManagedRemovalPreservesDataAndVersionEvidence(t *testing.T) { + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + i := m.installer + if err := os.MkdirAll(i.state, 0700); err != nil { + t.Fatal(err) + } + store, err := openQueueStore(i.state, true) + if err != nil { + t.Fatal(err) + } + store.close() + data := map[string][]byte{ + filepath.Join(i.config, "remote.json"): []byte(`{"enabled":true,"passwordHash":"fixture","csrfSecret":"retain"}`), + filepath.Join(i.state, "remote/caddy/root.crt"): []byte("fixture certificate"), + filepath.Join(i.state, "legacy-queue-import.json"): []byte(`{"tasks":{},"groups":{}}`), + filepath.Join(i.state, "legacy-slot.json"): mustMetadataJSON(t, Object{"slot": filepath.Join(i.home, "fixture-execution.slot")}), + filepath.Join(i.home, ".local/bin/other-tool"): []byte("custom unrelated tool"), + filepath.Join(i.root, "private-operator-note"): []byte("retain"), + } + for filename, raw := range data { + integrationTestWrite(t, filename, raw) + } + queueBefore := installBundleSnapshot(t, i.state) + bundle := installBundleSnapshot(t, i.app) + m.statuses["remote"] = "enabled" + m.loaded["io.went.agent-remote"] = true + managedRemovalTarget(i) + m.calls = nil + if err := m.run(t); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(bundle, installBundleSnapshot(t, i.app)) { + t.Fatal("removal modified app") + } + queueAfter := installBundleSnapshot(t, i.state) + if !reflect.DeepEqual(queueBefore, queueAfter) { + for filename, entry := range queueAfter { + if queueBefore[filename] != entry { + t.Errorf("removal modified queue entry %s: before=%+v after=%+v", filepath.Base(filename), queueBefore[filename], entry) + } + } + t.Fatal("removal modified queue or remote state") + } + for filename, expected := range data { + raw, err := os.ReadFile(filename) + if err != nil || string(raw) != string(expected) { + t.Fatal("removal changed retained data", filename, err) + } + } + for _, filename := range []string{filepath.Join(i.home, ".local/bin/agent-heavy"), filepath.Join(i.home, ".local/bin/agent-remote"), filepath.Join(i.root, "installation.json"), filepath.Join(i.root, "install-in-progress.json")} { + if _, err := os.Lstat(filename); !errors.Is(err, os.ErrNotExist) { + t.Fatal("removal retained generated installation file", filename, err) + } + } + completed, err := installReadInfo(filepath.Join(i.root, "build-info.json")) + if err != nil || completed.Build == nil || *completed.Build != 123 { + t.Fatal("removal lost version evidence", err) + } + for _, status := range m.statuses { + if status != "notRegistered" { + t.Fatal("removal retained registration", m.statuses) + } + } + if err := m.run(t); err != nil { + t.Fatal("removal retry", err) + } +} + +func TestManagedLifecycleForeignServicesAndCustomWrappersFailBeforeChanges(t *testing.T) { + for _, removal := range []bool{false, true} { + for _, kind := range []string{"queue", "remote", "custom-wrapper", "symlink-wrapper", "lifetime-lock", "orphaned-remote"} { + t.Run(map[bool]string{false: "prepare", true: "remove"}[removal]+"/"+kind, func(t *testing.T) { + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + i := m.installer + i.prepareReplacement = true + if removal { + managedRemovalTarget(i) + } + original := i.command + i.command = func(ctx context.Context, args []string) ([]byte, error) { + label := map[string]string{"queue": "io.went.agent-heavy", "remote": "io.went.agent-remote"}[kind] + if label != "" && slices.Equal(args, []string{"launchctl", "print", i.domain() + "/" + label}) { + return []byte("program = /foreign\narguments = {\n/foreign\n}\n"), nil + } + return original(ctx, args) + } + if kind == "custom-wrapper" { + integrationTestWrite(t, filepath.Join(i.home, ".local/bin/agent-heavy"), []byte("#!/bin/sh\necho custom\n")) + } + if kind == "symlink-wrapper" { + filename := filepath.Join(i.home, ".local/bin/agent-heavy") + if err := os.Remove(filename); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(i.app, "Contents/MacOS/agent-heavy"), filename); err != nil { + t.Fatal(err) + } + } + if kind == "lifetime-lock" { + m.statuses["resources"] = "notRegistered" + delete(m.loaded, "io.went.agent-resources") + lock, err := installLock(i.state, "resource-controller.lock") + if err != nil { + t.Fatal(err) + } + defer lock.Close() + } + if kind == "orphaned-remote" { + integrationTestWrite(t, filepath.Join(i.state, "remote/processes.json"), []byte(`{"active":true}`)) + } + before := installTreeWitness(t, i.home) + m.calls = nil + if err := m.run(t); err == nil { + t.Fatal("unsafe lifecycle request accepted", kind) + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("unsafe lifecycle request changed home", kind) + } + managedRequireNoServiceMutation(t, m.calls) + }) + } + } +} + +func TestManagedRemovalCanUnregisterDeniedApprovalWithoutEnabling(t *testing.T) { + m := managedTestFixture(t) + m.approval = "resources" + if err := m.run(t); err == nil { + t.Fatal("missing denied approval") + } + managedRemovalTarget(m.installer) + m.calls = nil + if err := m.run(t); err != nil { + t.Fatal(err) + } + unregisteredDenied := false + for _, args := range m.calls { + if len(args) == 4 && args[1] == "--service-management" { + if args[2] == "register" { + t.Fatal("removal tried enabling denied service", args) + } + unregisteredDenied = unregisteredDenied || args[2] == "unregister" && args[3] == "resources" + } + } + if !unregisteredDenied { + t.Fatal("denied service was not unregistered") + } +} + +func TestManagedLifecycleIncompleteUnregistrationPreservesAppAndMetadata(t *testing.T) { + for _, removal := range []bool{false, true} { + t.Run(map[bool]string{false: "prepare", true: "remove"}[removal], func(t *testing.T) { + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + i := m.installer + i.prepareReplacement = true + if removal { + managedRemovalTarget(i) + } + bundle := installBundleSnapshot(t, i.app) + wrappers, err := i.managedWrappers() + if err != nil { + t.Fatal(err) + } + original := i.command + i.command = func(ctx context.Context, args []string) ([]byte, error) { + if len(args) == 4 && args[1] == "--service-management" && args[2] == "unregister" && args[3] == "queue" { + return nil, nil // Bridge returned, but registration did not change. + } + return original(ctx, args) + } + if err := m.run(t); err == nil || !strings.Contains(err.Error(), "after unregister") { + t.Fatal("incomplete unregistration accepted", err) + } + if !reflect.DeepEqual(bundle, installBundleSnapshot(t, i.app)) { + t.Fatal("incomplete unregistration changed app") + } + current, err := i.managedWrappers() + if err != nil || !reflect.DeepEqual(current, wrappers) { + t.Fatal("incomplete unregistration retired wrappers", err) + } + for _, filename := range []string{"installation.json", "install-in-progress.json"} { + if _, err := os.Stat(filepath.Join(i.root, filename)); err != nil { + t.Fatal("incomplete unregistration retired custody", filename, err) + } + } + }) + } +} + +func TestManagedPreparationPreservesPueueImportAndSlotForPostflight(t *testing.T) { + m := managedTestFixture(t) + m.legacy(t) + i := m.installer + filename := filepath.Join(i.launch, "io.went.agent-resources.plist") + definition, err := updatePlist(filename) + if err != nil { + t.Fatal(err) + } + delete(definition, "EnvironmentVariables") + if err := i.writePlist(filename, definition); err != nil { + t.Fatal(err) + } + m.tasks["41"] = Object{"id": 41, "status": Object{"Done": Object{"result": "Success"}}} + i.prepareReplacement = true + if err := m.run(t); err != nil { + t.Fatal(err) + } + for _, filename := range []string{filepath.Join(i.state, "legacy-queue-import.json"), filepath.Join(i.state, "legacy-slot.json"), filepath.Join(i.root, "agent_heavy.py"), filepath.Join(i.root, ".venv/bin/python"), filepath.Join(i.root, "install-in-progress.json")} { + if _, err := os.Stat(filename); err != nil { + t.Fatal("preparation lost legacy retry state", filename, err) + } + } + if _, err := os.Lstat(i.app); !errors.Is(err, os.ErrNotExist) { + t.Fatal("preparation published the app", err) + } + if _, err := os.Lstat(filepath.Join(i.home, ".local/bin/agent-heavy")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("preparation published a wrapper", err) + } + if err := m.run(t); err != nil { + t.Fatal("Pueue preparation retry", err) + } + i.prepareReplacement = false + if err := m.run(t); err != nil { + t.Fatal("Pueue postflight", err) + } + legacy, err := ReadPrivate(filepath.Join(i.state, "legacy-queue-import.json")) + if err != nil || obj(obj(legacy["tasks"])["41"]) == nil { + t.Fatal("postflight lost completed legacy history", legacy, err) + } +} + +func TestManagedPendingCandidatePinsIdentityAndRemovalRetainsOrdering(t *testing.T) { + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + i := m.installer + candidate := managedCandidate(t, m, "124") + i.prepareReplacement = true + if err := m.run(t); err != nil { + t.Fatal(err) + } + for _, build := range []string{"123", "125"} { + managedCandidate(t, m, build) + before := installTreeWitness(t, i.home) + m.calls = nil + if err := m.run(t); err == nil { + t.Fatal("different pending candidate accepted", build) + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("pending identity refusal mutated installation") + } + managedRequireNoServiceMutation(t, m.calls) + } + managedRemovalTarget(i) // Installed app is still build 123, pending is 124. + if err := m.run(t); err != nil { + t.Fatal("explicit removal after preparation", err) + } + retained, err := installReadInfo(filepath.Join(i.root, "build-info.json")) + if err != nil || !retained.Equal(candidate) { + t.Fatal("removal reset pending version evidence", retained, err) + } + i.unregisterServices = false + before := installTreeWitness(t, i.home) + if err := m.run(t); err == nil || !strings.Contains(err.Error(), "older") { + t.Fatal("reinstallation lost anti-downgrade", err) + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("downgrade refusal mutated retained data") + } +} + +func TestManagedConcurrentRemoteDisableBeforeAndDuringMigration(t *testing.T) { + for _, phase := range []string{"before-migrate", "during-migrate"} { + t.Run(phase, func(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + if err := m.run(t); err != nil { + t.Fatal(err) + } + settings := filepath.Join(i.config, "remote.json") + integrationTestWrite(t, settings, []byte(`{"enabled":true,"passwordHash":"retain"}`)) + original := i.command + migrated, disabled := false, false + i.command = func(ctx context.Context, args []string) ([]byte, error) { + // The controller serializes both migrate and explicit disable using + // the real control.lock. A later disable must win even though the + // install plan observed enabled=true before shutting services down. + if slices.Equal(args, []string{filepath.Join(i.app, "Contents/MacOS/agent-remote"), "migrate"}) { + migrated = true + remoteState := filepath.Join(i.state, "remote") + // A separate actor performs the explicit disable. Channels bind + // both timings without sleeps, and every wait uses the install + // context so a broken serialization fails within the fixture budget. + startDisable := func(release <-chan struct{}, attempted chan<- struct{}) <-chan error { + done := make(chan error, 1) + go func() { + if release != nil { + probe, err := installLock(remoteState, "control.lock") + if err == nil { + probe.Close() + close(attempted) + done <- errors.New("disable acquired control.lock while migrate held it") + return + } + close(attempted) + select { + case <-ctx.Done(): + done <- ctx.Err() + return + case <-release: + } + } + lock, err := installLock(remoteState, "control.lock") + if err != nil { + done <- err + return + } + err = installAtomic(settings, []byte(`{"enabled":false,"passwordHash":"retain"}`), 0600) + if err == nil { + m.statuses["remote"] = "notRegistered" + delete(m.loaded, "io.went.agent-remote") + } + lock.Close() + done <- err + }() + return done + } + awaitDisable := func(done <-chan error) error { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-done: + disabled = err == nil + return err + } + } + if phase == "before-migrate" { + if err := awaitDisable(startDisable(nil, nil)); err != nil { + return nil, err + } + } + lock, err := installLock(remoteState, "control.lock") + if err != nil { + t.Fatal("installer retained control.lock across migrate", err) + } + enabled, err := i.remoteEnabled() + if err != nil { + lock.Close() + return nil, err + } + if enabled { + m.statuses["remote"] = "enabled" + m.loaded["io.went.agent-remote"] = true + } + if phase == "during-migrate" { + release, attempted := make(chan struct{}), make(chan struct{}) + done := startDisable(release, attempted) + select { + case <-ctx.Done(): + lock.Close() + close(release) + return nil, ctx.Err() + case <-attempted: + } + lock.Close() + close(release) + if err := awaitDisable(done); err != nil { + return nil, err + } + } else { + lock.Close() + } + return nil, nil + } + if disabled && len(args) == 4 && args[1] == "--service-management" && args[2] == "register" && args[3] == "remote" { + t.Fatal("stale remote plan re-enabled phone access") + } + if migrated && len(args) == 4 && args[1] == "--service-management" && args[2] == "status" && args[3] == "remote" { + busy, err := installInspectLock(filepath.Join(i.state, "remote"), "control.lock") + if err != nil || !busy { + t.Fatal("remote readback is not serialized through commit", busy, err) + } + } + return original(ctx, args) + } + if err := m.run(t); err != nil { + t.Fatal(err) + } + if !migrated || !disabled || m.statuses["remote"] != "notRegistered" || m.loaded["io.went.agent-remote"] { + t.Fatal("remote disable was not retained", migrated, disabled, m.statuses) + } + }) + } +} diff --git a/internal/backend/install_managed_review_test.go b/internal/backend/install_managed_review_test.go new file mode 100644 index 0000000..577a6da --- /dev/null +++ b/internal/backend/install_managed_review_test.go @@ -0,0 +1,492 @@ +package backend + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + "time" + + "github.com/Dunky13/agent-job-queue/internal/appservices" +) + +func TestManagedInPlaceRetryRetiresPublicationCustody(t *testing.T) { + m := managedRetainedBackupFixture(t) + i := m.installer + payload := i.payload + i.appBundle, i.payload = true, filepath.Join(i.app, "Contents/Resources/payload") + if err := m.run(t); err != nil { + t.Fatal("in-place completion failed", err) + } + for _, path := range []string{i.managedReceiptPath(), i.managedPreviousPath(), filepath.Join(i.root, "install-in-progress.json")} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatal("successful in-place completion retained publication custody", path, err) + } + } + i.appBundle, i.payload = false, payload + managedCandidate(t, m, "125") + if err := m.run(t); err != nil { + t.Fatal("in-place completion blocked subsequent payload upgrade", err) + } +} + +func TestManagedInPlaceRetryRefusesSubstitutedBackupBeforeMutation(t *testing.T) { + m := managedRetainedBackupFixture(t) + i := m.installer + previous := i.managedPreviousPath() + if err := os.Rename(previous, previous+".original"); err != nil { + t.Fatal(err) + } + if err := installCopyTree(previous+".original", previous); err != nil { + t.Fatal(err) + } + i.appBundle, i.payload = true, filepath.Join(i.app, "Contents/Resources/payload") + before := installTreeWitness(t, i.home) + m.calls = nil + if err := m.run(t); err == nil { + t.Fatal("in-place completion accepted a same-content replacement backup") + } + managedRequireNoServiceMutation(t, m.calls) + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("refusal changed publication evidence") + } +} + +func TestManagedInPlaceRetryRefusesUnwritableBackupParentBeforeMutation(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can write owner-read-only directories") + } + m := managedRetainedBackupFixture(t) + i := m.installer + i.appBundle, i.payload = true, filepath.Join(i.app, "Contents/Resources/payload") + parent := filepath.Dir(i.app) + if err := os.Chmod(parent, 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(parent, 0700) }) + before := installTreeWitness(t, i.home) + m.calls = nil + if err := m.run(t); err == nil { + t.Fatal("in-place retry accepted backup cleanup it could not finish") + } + managedRequireNoServiceMutation(t, m.calls) + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("unwritable backup parent refusal changed installation") + } +} + +func TestManagedLifecycleDoesNotStrandPublicationCustody(t *testing.T) { + for _, upgrade := range []bool{false, true} { + for _, removal := range []bool{false, true} { + t.Run(fmt.Sprintf("upgrade=%t/removal=%t", upgrade, removal), func(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + payload := i.payload + if upgrade { + if err := m.run(t); err != nil { + t.Fatal(err) + } + managedCandidate(t, m, "124") + } + m.nativeFailure = "register resources" + if err := m.run(t); err == nil { + t.Fatal("expected partial publication") + } + m.nativeFailure = "" + i.prepareReplacement = true + if removal { + managedRemovalTarget(i) + } + before := installTreeWitness(t, i.home) + m.calls = nil + err := m.run(t) + if removal && !upgrade { + if err != nil { + t.Fatal("candidate-only removal failed", err) + } + for _, path := range []string{i.managedReceiptPath(), filepath.Join(i.root, "install-in-progress.json")} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatal("removal stranded candidate-only publication custody", path, err) + } + } + } else { + if err == nil || !strings.Contains(err.Error(), "pending publication") { + t.Fatal("lifecycle operation authorized unresolved publication", err) + } + managedRequireNoServiceMutation(t, m.calls) + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("refusal changed retained publication evidence") + } + // Complete the exact pending candidate through its supported retry. + i.prepareReplacement, i.unregisterServices, i.appBundle = false, false, true + i.payload = filepath.Join(i.app, "Contents/Resources/payload") + if err := m.run(t); err != nil { + t.Fatal("exact pending completion failed", err) + } + } + i.prepareReplacement, i.unregisterServices, i.appBundle = false, false, false + i.payload = payload + if upgrade { + managedCandidate(t, m, "125") + } + if err := m.run(t); err != nil { + t.Fatal("resolved lifecycle custody blocked subsequent installation", err) + } + }) + } + } +} + +func TestManagedInstallerComposesRemoteAndNativeDeadlines(t *testing.T) { + outer, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + for _, action := range []string{"disable", "migrate"} { + remote, stop := installCommandContext(outer, []string{"/Applications/Agent Queue.app/Contents/MacOS/agent-remote", action}) + defer stop() + deadline, ok := remote.Deadline() + if !ok || time.Until(deadline) < appservices.ControllerLifecycleTimeout { + t.Fatal("installer truncates remote control lifecycle", action) + } + controller, finish := context.WithTimeout(remote, appservices.ControllerLifecycleTimeout) + defer finish() + native, release := installCommandContext(controller, []string{"/Applications/Agent Queue.app/Contents/MacOS/AgentQueue", "--service-management", "unregister", "remote"}) + defer release() + nativeDeadline, ok := native.Deadline() + if !ok || time.Until(nativeDeadline) < appservices.NativeCommandTimeout-time.Second { + t.Fatal("composed lifecycle truncates native completion") + } + } +} + +func TestManagedPayloadRefusesUnwritableAppBeforeStoppingServices(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can write owner-read-only directories") + } + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + i := m.installer + directory := filepath.Join(i.app, "Contents") + info, err := os.Stat(directory) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(directory, 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(directory, info.Mode().Perm()) }) + before := installTreeWitness(t, i.home) + m.calls = nil + if err := m.run(t); err == nil { + t.Fatal("unwritable application was replaced") + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("unwritable app refusal changed installation") + } + managedRequireNoServiceMutation(t, m.calls) +} + +func TestManagedPayloadResolvesBothRecordedApplicationsLocations(t *testing.T) { + for _, system := range []bool{false, true} { + t.Run(fmt.Sprint(system), func(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + app := filepath.Join(i.home, "Applications/Finder Copied Queue.app") + if system { + app = "/Applications/Finder Copied Queue.app" + } + integrationTestWrite(t, filepath.Join(i.root, "installation.json"), mustMetadataJSON(t, Object{"schemaVersion": 1, "serviceManagement": true, "app": app})) + before := installTreeWitness(t, i.home) + if err := i.resolvePayloadApp(); err != nil || i.app != app { + t.Fatal("recorded Applications target was not selected", i.app, err) + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) || len(m.calls) != 0 { + t.Fatal("resolving a recorded target changed state or invoked lifecycle") + } + i.appBundle, i.app = true, filepath.Join(i.home, "Applications/Explicit Queue.app") + if err := i.resolvePayloadApp(); err != nil || i.app == app { + t.Fatal("recorded metadata redirected an explicit app-bundle target", err) + } + }) + } +} + +func TestManagedPayloadUpgradeAndPreflightUseRecordedTarget(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + payload, defaultApp := i.payload, i.app + i.app = filepath.Join(i.home, "Applications/Finder Copied Queue.app") + if err := installCopyTree(filepath.Join(payload, "Agent Queue.app"), i.app); err != nil { + t.Fatal(err) + } + recorded := i.app + i.appBundle, i.payload = true, filepath.Join(i.app, "Contents/Resources/payload") + if err := m.run(t); err != nil { + t.Fatal(err) + } + i.appBundle, i.payload, i.app = false, payload, defaultApp + before := installTreeWitness(t, i.home) + report := i.preflight(context.Background()) + if len(report.Issues) != 0 || i.app != recorded || !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("payload preflight did not read the recorded target without mutation", report, i.app) + } + i.app = defaultApp + if err := m.run(t); err != nil || i.app != recorded { + t.Fatal("ordinary payload upgrade lost recorded target", i.app, err) + } + if _, err := os.Stat(defaultApp); !os.IsNotExist(err) { + t.Fatal("payload upgrade created a second app at its default location", err) + } +} + +func TestManagedReadinessUsesPublicStatusSchema(t *testing.T) { + value := Object{"updatedAt": time.Now().UTC().Format(time.RFC3339Nano), "queue": Object{"status": "running", "limit": 1, "running": 0, "waiting": 0}, "jobs": []any{}, "scheduler": Object{}} + if err := managedReadiness(mustMetadataJSON(t, value)); err != nil { + t.Fatal("public CLI status was refused", err) + } + for _, invalid := range []Object{ + {"tasks": Object{}, "groups": Object{}}, + {"updatedAt": value["updatedAt"], "queue": value["queue"], "jobs": nil, "scheduler": Object{}}, + {"updatedAt": "invalid", "queue": value["queue"], "jobs": []any{}, "scheduler": Object{}}, + {"updatedAt": value["updatedAt"], "queue": Object{"status": "running", "limit": 0, "running": 0, "waiting": 0}, "jobs": []any{}, "scheduler": Object{}}, + {"updatedAt": value["updatedAt"], "queue": value["queue"], "jobs": []any{}}, + } { + if err := managedReadiness(mustMetadataJSON(t, invalid)); err == nil { + t.Fatal("invalid/private status certified readiness", invalid) + } + } +} + +func TestManagedPublicationKeepsPendingIdentityAuthoritative(t *testing.T) { + for _, changed := range []string{"candidate", "installed"} { + t.Run(changed, func(t *testing.T) { + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + i := m.installer + managedCandidate(t, m, "124") + mutated := false + original := i.command + i.command = func(ctx context.Context, args []string) ([]byte, error) { + last := args[len(args)-1] + if !mutated && args[0] == "/usr/bin/codesign" && strings.HasSuffix(last, ".app") && strings.Contains(last, ".Agent Queue.installing-") { + mutated = true + target := last + if changed == "installed" { + target = i.app + } + identity := metadataTestInfo(t, "125") + for _, file := range []string{"Contents/Resources/build-info.json", "Contents/Resources/payload/source/build-info.json"} { + integrationTestWrite(t, filepath.Join(target, file), mustMetadataJSON(t, identity)) + } + info, err := updatePlist(filepath.Join(target, "Contents/Info.plist")) + if err != nil { + t.Fatal(err) + } + info["CFBundleVersion"] = "125" + if err := i.writePlist(filepath.Join(target, "Contents/Info.plist"), info); err != nil { + t.Fatal(err) + } + } + return original(ctx, args) + } + if err := m.run(t); err == nil || !mutated { + t.Fatal("publication accepted identity changed during staging", changed, err) + } + data, err := installReadPayload(i.app, filepath.Join(i.app, "Contents/Resources/build-info.json"), 16384, true) + if err != nil { + t.Fatal(err) + } + actual, err := ValidateBuildInfo(data) + want := 123 + if changed == "installed" { + want = 125 + } + if err != nil || actual.Build == nil || *actual.Build != want { + t.Fatal("conflicting staging replaced the existing app", actual, err) + } + for _, service := range []string{"queue", "resources", "login"} { + if m.statuses[service] != "notRegistered" { + t.Fatal("conflicting staging activated a service", service, m.statuses[service]) + } + } + }) + } +} + +func TestManagedRefusedMenuShutdownPreservesPreviousBundle(t *testing.T) { + for _, response := range []string{"refused", "invalid-json", "wrong-app", "wrong-schema"} { + for _, operation := range []string{"install", "prepare", "remove"} { + t.Run(response+"/"+operation, func(t *testing.T) { + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + i := m.installer + if operation == "prepare" { + i.prepareReplacement = true + } else if operation == "remove" { + managedRemovalTarget(i) + } + before := installBundleSnapshot(t, i.app) + original := i.command + i.command = func(ctx context.Context, args []string) ([]byte, error) { + if len(args) == 3 && args[1] == "--prepare-app-replacement" { + switch response { + case "refused": + return nil, fmt.Errorf("menu refused termination") + case "invalid-json": + return []byte("invalid"), nil + case "wrong-app": + return mustMetadataJSON(t, Object{"schemaVersion": 1, "app": "/Applications/Other.app", "status": "stopped"}), nil + case "wrong-schema": + return mustMetadataJSON(t, Object{"schemaVersion": 2, "app": i.app, "status": "stopped"}), nil + } + } + return original(ctx, args) + } + if err := m.run(t); err == nil { + t.Fatal("unconfirmed menu shutdown allowed replacement") + } + if !reflect.DeepEqual(before, installBundleSnapshot(t, i.app)) { + t.Fatal("bundle changed after unconfirmed UI shutdown") + } + if _, err := os.Stat(filepath.Join(i.root, "install-in-progress.json")); err != nil { + t.Fatal("refused shutdown lost pending identity", err) + } + }) + } + } +} + +func TestManagedDirectInstallRefusesForeignWrappersBeforeMutation(t *testing.T) { + for _, appBundle := range []bool{false, true} { + for _, name := range []string{"agent-heavy", "agent-remote"} { + for _, symlink := range []bool{false, true} { + t.Run(fmt.Sprintf("bundle=%v/%s/symlink=%v", appBundle, name, symlink), func(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + if appBundle { + if err := installCopyTree(filepath.Join(i.payload, "Agent Queue.app"), i.app); err != nil { + t.Fatal(err) + } + i.appBundle, i.payload = true, filepath.Join(i.app, "Contents/Resources/payload") + } + wrapper := filepath.Join(i.home, ".local/bin", name) + integrationTestWrite(t, wrapper, []byte("#!/bin/sh\necho custom\n")) + if symlink { + if err := os.Rename(wrapper, wrapper+".custom"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(wrapper+".custom", wrapper); err != nil { + t.Fatal(err) + } + } + before := installTreeWitness(t, i.home) + if err := m.run(t); err == nil { + t.Fatal("foreign wrapper accepted") + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("wrapper refusal changed installation") + } + managedRequireNoServiceMutation(t, m.calls) + }) + } + } + } +} + +func TestManagedDirectInstallRechecksWrappersUnderLocks(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + calls := 0 + i.queueSnapshot = func(context.Context) (Object, error) { + calls++ + if calls == 1 { + integrationTestWrite(t, filepath.Join(i.home, ".local/bin/agent-heavy"), []byte("custom during planning")) + } + return m.snapshot(), nil + } + // Force the drained legacy snapshot so the concurrent fixture edit occurs + // after the first wrapper inspection and before the locked plan. + for _, directory := range []string{i.launch, i.state} { + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + } + if err := i.validateServices(); err != nil { + t.Fatal(err) + } + if err := i.serviceDefinitions(false); err != nil { + t.Fatal(err) + } + m.loaded["io.went.agent-heavy"] = true + if err := m.run(t); err == nil || calls == 0 { + t.Fatal("locked planning accepted a changed wrapper", calls, err) + } + managedRequireNoServiceMutation(t, m.calls) + if _, err := os.Stat(filepath.Join(i.root, "install-in-progress.json")); !os.IsNotExist(err) { + t.Fatal("wrapper race published a pending identity", err) + } +} + +func TestManagedHistoricalCaskRemovedMenuDefinition(t *testing.T) { + for _, kind := range []string{"canonical", "foreign-program", "foreign-app", "extra-argument"} { + t.Run(kind, func(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + for _, directory := range []string{i.launch, i.state} { + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + } + if err := i.validateServices(); err != nil { + t.Fatal(err) + } + if err := i.serviceDefinitions(false); err != nil { + t.Fatal(err) + } + // This is the predecessor cask's actual deletion before successor + // preflight. Its loaded command remains in the user's launchd domain. + if err := os.Remove(filepath.Join(i.launch, "io.went.agent-queue.plist")); err != nil { + t.Fatal(err) + } + m.loaded["io.went.agent-queue"] = true + program, app, extra := "/usr/bin/open", i.app, "" + switch kind { + case "foreign-program": + program = "/foreign/open" + case "foreign-app": + app = filepath.Join(i.home, "Other.app") + case "extra-argument": + extra = "--args\n" + } + original := i.command + i.command = func(ctx context.Context, args []string) ([]byte, error) { + if m.loaded["io.went.agent-queue"] && slices.Equal(args, []string{"launchctl", "print", i.domain() + "/io.went.agent-queue"}) { + return []byte("program = " + program + "\narguments = {\n/usr/bin/open\n-a\n" + app + "\n" + extra + "}\n"), nil + } + return original(ctx, args) + } + before := installTreeWitness(t, i.home) + err := m.run(t) + if kind == "canonical" { + if err != nil || m.loaded["io.went.agent-queue"] { + t.Fatal("historical loaded menu was not safely retired", err) + } + } else { + if err == nil || !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("foreign orphaned menu was changed", err) + } + managedRequireNoServiceMutation(t, m.calls) + } + }) + } +} diff --git a/internal/backend/install_managed_shared_test.go b/internal/backend/install_managed_shared_test.go new file mode 100644 index 0000000..e402230 --- /dev/null +++ b/internal/backend/install_managed_shared_test.go @@ -0,0 +1,417 @@ +package backend + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func managedSharedFixture(t *testing.T) (string, *os.File) { + t.Helper() + base, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(base, "shared") + if err := os.Mkdir(path, 0775); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0775); err != nil { + t.Fatal(err) + } + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + parent := os.NewFile(uintptr(fd), path) + t.Cleanup(func() { parent.Close() }) + return path, parent +} + +// This low-level FD copier does not confer publication or execution custody. +// The production wrapper below must refuse a foreign-writable parent even when +// this primitive can copy bytes without following a substituted symlink. +func TestManagedSharedCopyPreservesPrivateBoundary(t *testing.T) { + f := installTestFixture(t) + shared, parent := managedSharedFixture(t) + source := filepath.Join(f.installer.payload, "Agent Queue.app") + if err := installCopyTree(source, filepath.Join(shared, "legacy.app")); err == nil || !strings.Contains(err.Error(), "group/world-writable parent") { + t.Fatalf("legacy copier did not reproduce private-parent refusal: %v", err) + } + if err := managedCopyTreeAt(context.Background(), source, parent, "candidate.app"); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(shared, "candidate.app/Contents/MacOS/AgentQueue")) + if err != nil || string(raw) != "prebuilt app" { + t.Fatalf("copied bytes: %q %v", raw, err) + } + info, err := os.Stat(filepath.Join(shared, "candidate.app/Contents/MacOS/AgentQueue")) + if err != nil || info.Mode().Perm() != 0700 { + t.Fatalf("executable mode: %v %v", info, err) + } + if info, err := parent.Stat(); err != nil || info.Mode().Perm() != 0775 { + t.Fatalf("shared parent permissions changed: %v %v", info, err) + } + if private, err := integrationParent(filepath.Join(shared, "private-state/lock"), true); err == nil { + private.Close() + t.Fatal("private state checker accepted shared ancestor") + } + if _, err := os.Stat(filepath.Join(shared, "private-state")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("private state was created: %v", err) + } +} + +func TestManagedSharedCopyRefusesUnsafeEntries(t *testing.T) { + for _, kind := range []string{"source-link", "child-link", "hardlink", "world-writable", "setuid", "fifo", "destination-link", "destination-existing", "escape", "oversize", "cancelled"} { + t.Run(kind, func(t *testing.T) { + f := installTestFixture(t) + shared, parent := managedSharedFixture(t) + source := filepath.Join(f.installer.payload, "Agent Queue.app") + file := filepath.Join(source, "Contents/MacOS/AgentQueue") + outside := filepath.Join(f.installer.home, "outside") + integrationTestWrite(t, outside, []byte("preserved")) + name := "candidate.app" + ctx := context.Background() + var err error + switch kind { + case "source-link": + link := filepath.Join(f.installer.payload, "source-link.app") + err = os.Symlink(source, link) + source = link + case "child-link": + err = os.Symlink(outside, filepath.Join(source, "link")) + case "hardlink": + err = os.Link(file, filepath.Join(source, "hardlink")) + case "world-writable": + err = os.Chmod(file, 0722) + case "setuid": + err = os.Chmod(file, 0700|os.ModeSetuid) + case "fifo": + err = unix.Mkfifo(filepath.Join(source, "fifo"), 0600) + case "destination-link": + err = os.Symlink(outside, filepath.Join(shared, name)) + case "destination-existing": + err = os.Mkdir(filepath.Join(shared, name), 0700) + case "escape": + name = "../escape.app" + case "oversize": + err = os.Truncate(file, updateMaxArchive+1) + case "cancelled": + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + } + if err != nil { + t.Fatal(err) + } + if err := managedCopyTreeAt(ctx, source, parent, name); err == nil { + t.Fatal("unsafe copy succeeded") + } + if slices.Contains([]string{"source-link", "child-link", "hardlink", "world-writable", "setuid", "fifo", "oversize"}, kind) { + if err := managedCopySourceAccess(source); err == nil { + t.Fatal("writer refusal was not detected by read-only preflight") + } + } + if raw, err := os.ReadFile(outside); err != nil || string(raw) != "preserved" { + t.Fatalf("outside destination changed: %q %v", raw, err) + } + }) + } +} + +func TestManagedSharedCopyPinsParentDescriptor(t *testing.T) { + f := installTestFixture(t) + shared, parent := managedSharedFixture(t) + retained := shared + "-retained" + if err := os.Rename(shared, retained); err != nil { + t.Fatal(err) + } + outside := filepath.Join(f.installer.home, "outside") + if err := os.Mkdir(outside, 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, shared); err != nil { + t.Fatal(err) + } + if err := managedCopyTreeAt(context.Background(), filepath.Join(f.installer.payload, "Agent Queue.app"), parent, "candidate.app"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(retained, "candidate.app/Contents/MacOS/AgentQueue")); err != nil { + t.Fatal(err) + } + if entries, err := os.ReadDir(outside); err != nil || len(entries) != 0 { + t.Fatalf("copy escaped held directory: %v %v", entries, err) + } +} + +func TestManagedSharedCopyBoundsAndOwnership(t *testing.T) { + for _, name := range []string{"", ".", "..", "/", "/absolute.app", "../escape.app", "nested/child.app"} { + if managedCopyName(name) { + t.Fatalf("non-child staging name accepted: %q", name) + } + } + for _, kind := range []string{"foreign", "unsafe-directory", "hardlink", "special", "total-bytes", "entries", "depth"} { + t.Run(kind, func(t *testing.T) { + info := unix.Stat_t{Mode: unix.S_IFREG | 0600, Uid: uint32(os.Getuid()), Nlink: 1} + directory := false + switch kind { + case "foreign": + info.Uid = uint32(os.Getuid()) + 1 + case "unsafe-directory": + info.Mode = unix.S_IFDIR | 0775 + directory = true + case "hardlink": + info.Nlink = 2 + case "special": + info.Mode = unix.S_IFSOCK | 0600 + default: + f := installTestFixture(t) + _, parent := managedSharedFixture(t) + input, err := os.Open(filepath.Join(f.installer.payload, "Agent Queue.app")) + if err != nil { + t.Fatal(err) + } + defer input.Close() + budget := &managedCopyBudget{} + depth := 0 + switch kind { + case "total-bytes": + budget.bytes = updateMaxExpanded + case "entries": + budget.entries = 10000 + case "depth": + depth = 129 + } + if err := budget.directory(context.Background(), input, parent, depth); err == nil { + t.Fatal("copy limit was ignored") + } + return + } + if err := managedCopyInfo(&info, directory); err == nil { + t.Fatal("unsafe ownership/type/permission accepted") + } + }) + } +} + +func TestManagedSharedAppStagesThenMovesThroughFD(t *testing.T) { + f := managedTestFixture(t) + shared, parent := managedSharedFixture(t) + if err := f.installer.managedCopyAppAt(context.Background(), filepath.Join(f.installer.payload, "Agent Queue.app"), parent, "candidate.app"); err == nil { + t.Fatal("group-writable publication parent accepted") + } + if err := os.Chmod(shared, 0700); err != nil { + t.Fatal(err) + } + if err := f.installer.managedCopyAppAt(context.Background(), filepath.Join(f.installer.payload, "Agent Queue.app"), parent, "candidate.app"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(shared, "candidate.app/Contents/MacOS/AgentQueue")); err != nil { + t.Fatal(err) + } + workspaces, err := filepath.Glob(filepath.Join(f.installer.home, ".AgentQueue-staging-*")) + if err != nil || len(workspaces) != 0 { + t.Fatalf("private staging leaked: %v %v", workspaces, err) + } + if info, err := parent.Stat(); err != nil || info.Mode().Perm() != 0700 { + t.Fatalf("shared parent permissions changed: %v %v", info, err) + } +} + +func TestManagedSharedCopyDetectsReboundChild(t *testing.T) { + f := installTestFixture(t) + shared, parent := managedSharedFixture(t) + if err := os.Mkdir(filepath.Join(shared, "child"), 0700); err != nil { + t.Fatal(err) + } + child, err := os.Open(filepath.Join(shared, "child")) + if err != nil { + t.Fatal(err) + } + defer child.Close() + if err := os.Rename(filepath.Join(shared, "child"), filepath.Join(shared, "retained")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(f.installer.payload, filepath.Join(shared, "child")); err != nil { + t.Fatal(err) + } + if err := managedCopyNamed(parent, "child", child); err == nil { + t.Fatal("rebound directory was accepted") + } + input, err := os.Open(filepath.Join(f.installer.payload, "Agent Queue.app")) + if err != nil { + t.Fatal(err) + } + defer input.Close() + if err := (&managedCopyBudget{}).entry(context.Background(), input, parent, "child", 0); err == nil { + t.Fatal("destination child symlink was followed") + } +} + +func TestManagedPublicationRefusesReadonlyCodeBeforeStopping(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses owner write permissions") + } + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + file := filepath.Join(m.installer.app, "Contents/MacOS/agent-heavy") + if err := os.Chmod(file, 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(file, 0700) }) + m.calls = nil + if err := m.run(t); err == nil || !strings.Contains(err.Error(), "not writable") { + t.Fatalf("read-only code was accepted: %v", err) + } + managedRequireNoServiceMutation(t, m.calls) +} + +func TestManagedPublicationRechecksAccessAfterStaging(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses owner write permissions") + } + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + i := m.installer + before, err := managedBundleDigest(i.app) + if err != nil { + t.Fatal(err) + } + managedCandidate(t, m, "124") + file := filepath.Join(i.app, "Contents/MacOS/agent-heavy") + t.Cleanup(func() { _ = os.Chmod(file, 0700) }) + original := i.command + i.command = func(ctx context.Context, args []string) ([]byte, error) { + if args[0] == "/usr/bin/codesign" && strings.Contains(args[len(args)-1], ".Agent Queue.installing-") { + if err := os.Chmod(file, 0500); err != nil { + t.Fatal(err) + } + } + return original(ctx, args) + } + if err := i.publishManaged(context.Background()); err == nil || !strings.Contains(err.Error(), "not writable") { + t.Fatalf("publication did not recheck access: %v", err) + } + if after, err := managedBundleDigest(i.app); err != nil || before != after { + t.Fatalf("old app changed after publication refusal: %v", err) + } +} + +func TestManagedPublicationKeepsExplicitImmutableSetup(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + if err := os.MkdirAll(filepath.Dir(i.app), 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(filepath.Dir(i.app), 0700) }) + i.appBundle = true + if err := i.managedPublicationAccess(); err != nil { + t.Fatalf("explicit immutable setup required publication permission: %v", err) + } + if info, err := os.Stat(filepath.Dir(i.app)); err != nil || info.Mode().Perm() != 0500 { + t.Fatalf("in-place setup changed publication permissions: %v %v", info, err) + } +} + +func TestManagedSharedNativeCopyRetainsMetadataAndVerifiesBeforeMove(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("native ditto metadata requires macOS") + } + for _, kind := range []string{"metadata", "signature-refusal", "unsafe-parent-at-publication"} { + t.Run(kind, func(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + i.testing = false + shared, parent := managedSharedFixture(t) + if err := os.Chmod(shared, 0700); err != nil { + t.Fatal(err) + } + source := filepath.Join(i.payload, "Agent Queue.app") + if err := unix.Setxattr(source, "com.apple.quarantine", []byte("0081;6f35b100;AgentQueueInstaller;"), 0); err != nil { + t.Fatal(err) + } + if err := unix.Setxattr(source, "io.went.agent-queue.fixture", []byte("preserved"), 0); err != nil { + t.Fatal(err) + } + original := i.command + native, verified := false, false + i.command = func(ctx context.Context, args []string) ([]byte, error) { + if args[0] == "/usr/bin/ditto" { + native = true + if len(args) != 7 || !slices.Equal(args[1:5], []string{"--rsrc", "--extattr", "--qtn", "--acl"}) || args[5] != source || !strings.HasPrefix(args[6], i.home+"/.AgentQueue-staging-") { + t.Fatalf("unexpected native copy contract: %v", args) + } + return exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput() + } + if args[0] == "/usr/bin/codesign" { + verified = true + if _, err := os.Lstat(filepath.Join(shared, "candidate.app")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("published before signature verification: %v", err) + } + if kind == "signature-refusal" { + return nil, errors.New("simulated signature failure") + } + if kind == "unsafe-parent-at-publication" { + if err := os.Chmod(shared, 0777); err != nil { + t.Fatal(err) + } + } + } + return original(ctx, args) + } + err := i.managedCopyAppAt(context.Background(), source, parent, "candidate.app") + if !native || !verified { + t.Fatalf("native/signature gates not reached: %v %v: %v", native, verified, err) + } + if kind != "metadata" { + want := "signature" + if kind == "unsafe-parent-at-publication" { + want = "unsafe parent" + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("publication failure ignored: %v", err) + } + if _, err := os.Lstat(filepath.Join(shared, "candidate.app")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unsafe candidate was published: %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + for attribute, prefix := range map[string]string{"com.apple.quarantine": "0081;", "io.went.agent-queue.fixture": "preserved"} { + data := make([]byte, 1024) + n, err := unix.Getxattr(filepath.Join(shared, "candidate.app"), attribute, data) + if err != nil || !strings.HasPrefix(string(data[:n]), prefix) { + t.Fatalf("lost %s: %q %v", attribute, data[:n], err) + } + } + }) + } +} + +func TestManagedPublicationRejectsUnsafeParentBeforeServiceChanges(t *testing.T) { + m := managedTestFixture(t) + shared, _ := managedSharedFixture(t) + m.installer.app = filepath.Join(shared, "Agent Queue.app") + if _, err := m.installer.planManaged(context.Background()); err == nil { + t.Fatal("arbitrary shared ancestor accepted") + } + for _, args := range m.calls { + if slices.Contains(args, "unregister") || slices.Contains(args, "register") || slices.Contains(args, "--prepare-app-replacement") { + t.Fatalf("preflight changed a service: %v", args) + } + } +} diff --git a/internal/backend/install_managed_test.go b/internal/backend/install_managed_test.go new file mode 100644 index 0000000..7596fef --- /dev/null +++ b/internal/backend/install_managed_test.go @@ -0,0 +1,722 @@ +package backend + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + "time" + + "github.com/Dunky13/agent-job-queue/internal/appservices" +) + +type managedFixture struct { + *installFixture + statuses map[string]string + approval string + nativeFailure string + badStatus bool +} + +func managedTestFixture(t *testing.T) *managedFixture { + t.Helper() + f := installTestFixture(t) + app := filepath.Join(f.installer.payload, "Agent Queue.app") + info, err := updatePlist(filepath.Join(app, "Contents/Info.plist")) + if err != nil { + t.Fatal(err) + } + info["AgentQueueServiceManagement"] = true + if err := f.installer.writePlist(filepath.Join(app, "Contents/Info.plist"), info); err != nil { + t.Fatal(err) + } + for _, name := range []string{"agent-heavy", "agent-remote"} { + integrationTestWrite(t, filepath.Join(app, "Contents/MacOS", name), []byte("#!/bin/sh\nexit 0\n")) + if err := os.Chmod(filepath.Join(app, "Contents/MacOS", name), 0700); err != nil { + t.Fatal(err) + } + } + for service, command := range map[string]string{"heavy": "daemon", "resources": "scheduler", "remote": "serve"} { + helper := "agent-heavy" + if service == "remote" { + helper = "agent-remote" + } + filename := filepath.Join(app, "Contents/Library/LaunchAgents/io.went.agent-"+service+".plist") + integrationTestWrite(t, filename, []byte("placeholder")) + if err := f.installer.writePlist(filename, Object{"Label": "io.went.agent-" + service, "BundleProgram": "Contents/MacOS/" + helper, "ProgramArguments": []string{helper, command}}); err != nil { + t.Fatal(err) + } + } + if err := installCopyTree(filepath.Join(f.installer.payload, "source"), filepath.Join(app, "Contents/Resources/payload/source")); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Join(app, "Contents/Resources/payload/source/bin")); err != nil { + t.Fatal(err) + } + m := &managedFixture{installFixture: f, statuses: map[string]string{"queue": "notRegistered", "resources": "notRegistered", "remote": "notRegistered", "login": "notRegistered"}} + original := f.installer.command + f.installer.queueSnapshot = func(context.Context) (Object, error) { return f.snapshot(), nil } + f.installer.command = func(ctx context.Context, args []string) ([]byte, error) { + if len(args) == 3 && args[1] == "--prepare-app-replacement" { + f.calls = append(f.calls, slices.Clone(args)) + return mustMetadataJSON(t, Object{"schemaVersion": 1, "app": args[2], "status": "stopped"}), nil + } + if len(args) == 4 && args[1] == "--service-management" { + f.calls = append(f.calls, slices.Clone(args)) + action, service := args[2], args[3] + if m.nativeFailure == action+" "+service { + return nil, errors.New("simulated native failure") + } + if action == "status" { + if m.badStatus { + return []byte(`{"schemaVersion":1,"service":"queue","status":"enabled","unexpected":true}`), nil + } + return mustMetadataJSON(t, appservices.Status{SchemaVersion: 1, Service: service, Status: m.statuses[service]}), nil + } + label := map[string]string{"queue": "io.went.agent-heavy", "resources": "io.went.agent-resources", "remote": "io.went.agent-remote"}[service] + if action == "register" { + if _, err := os.Stat(filepath.Join(f.installer.app, "Contents/MacOS/agent-heavy")); err != nil { + t.Fatal("registration preceded complete bundle publication", err) + } + if _, err := os.Stat(filepath.Join(f.installer.root, "install-in-progress.json")); err != nil { + t.Fatal("registration lost pending marker", err) + } + m.statuses[service] = "enabled" + if m.approval == service { + m.statuses[service] = "requiresApproval" + } + if label != "" && m.statuses[service] == "enabled" { + f.loaded[label] = true + } + } else if action == "unregister" { + m.statuses[service] = "notRegistered" + delete(f.loaded, label) + } + return nil, nil + } + if len(args) > 2 && args[0] == "launchctl" && args[1] == "print" { + label := filepath.Base(args[2]) + service := map[string]string{"io.went.agent-heavy": "queue", "io.went.agent-resources": "resources", "io.went.agent-remote": "remote"}[label] + if service != "" && m.statuses[service] == "enabled" && f.loaded[label] { + f.calls = append(f.calls, slices.Clone(args)) + helper, command := "agent-heavy", "daemon" + if service == "resources" { + command = "scheduler" + } + if service == "remote" { + helper, command = "agent-remote", "serve" + } + executable := filepath.Join(f.installer.app, "Contents/MacOS", helper) + return []byte("program = " + executable + "\narguments = {\n" + helper + "\n" + command + "\n}\n"), nil + } + } + if len(args) == 3 && args[0] == filepath.Join(f.installer.app, "Contents/MacOS/agent-heavy") && args[1] == "status" { + f.calls = append(f.calls, slices.Clone(args)) + return mustMetadataJSON(t, Object{"updatedAt": time.Now().UTC().Format(time.RFC3339Nano), "queue": Object{"status": "running", "limit": 1, "running": 0, "waiting": 0}, "jobs": []any{}, "scheduler": Object{}}), nil + } + return original(ctx, args) + } + return m +} + +func TestManagedFreshInstallUsesOnlyBundleHelpers(t *testing.T) { + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + i := m.installer + for _, path := range []string{i.launch, filepath.Join(i.root, "bin"), filepath.Join(i.root, "install-in-progress.json")} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unexpected legacy or pending path %s: %v", path, err) + } + } + metadata, err := appservices.Load(i.home) + if err != nil || metadata == nil || metadata.App != i.app { + t.Fatalf("metadata %v: %v", metadata, err) + } + app, err := updateRegisteredApp() + if err != nil || app != i.app { + t.Fatalf("updater location %q: %v", app, err) + } + for _, name := range []string{"agent-heavy", "agent-remote"} { + raw, err := os.ReadFile(filepath.Join(i.home, ".local/bin", name)) + if err != nil || !strings.Contains(string(raw), installQuote(filepath.Join(i.app, "Contents/MacOS", name))) || strings.Contains(string(raw), "share/agent-heavy/bin") { + t.Fatalf("incorrect wrapper: %s %v", raw, err) + } + } + for _, args := range m.calls { + if args[0] == "launchctl" && args[1] != "print" { + t.Fatalf("fresh install used user service mutation %v", args) + } + } +} + +func TestManagedFinderBundleAndPreflightAreReadOnly(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + if err := installCopyTree(filepath.Join(i.payload, "Agent Queue.app"), i.app); err != nil { + t.Fatal(err) + } + i.appBundle = true + i.payload = filepath.Join(i.app, "Contents/Resources/payload") + before := installTreeWitness(t, i.home) + report := i.preflight(context.Background()) + if len(report.Issues) != 0 { + t.Fatalf("fresh preflight blockers: %+v", report.Issues) + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("preflight modified home") + } + for _, args := range m.calls { + if len(args) > 2 && args[1] == "--service-management" && args[2] != "status" { + t.Fatal("preflight mutated service", args) + } + } + bundle := installBundleSnapshot(t, i.app) + if err := m.run(t); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(bundle, installBundleSnapshot(t, i.app)) { + t.Fatal("Finder-installed bundle was modified") + } +} + +func TestManagedApprovalFailureRetainsPendingAndNeverReregisters(t *testing.T) { + m := managedTestFixture(t) + m.approval = "resources" + if err := m.run(t); err == nil { + t.Fatal("approval refusal completed install") + } + i := m.installer + if _, err := os.Stat(filepath.Join(i.root, "install-in-progress.json")); err != nil { + t.Fatal("lost partial-failure marker", err) + } + if _, err := updateRegisteredApp(); err == nil { + t.Fatal("updater ignored pending installation") + } + before := installTreeWitness(t, i.home) + m.calls = nil + if err := m.run(t); err == nil { + t.Fatal("approval-required retry completed") + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("denied approval retry changed installation") + } + for _, args := range m.calls { + if len(args) > 2 && args[1] == "--service-management" && args[2] != "status" { + t.Fatal("re-registered approval-required service", args) + } + } + m.approval = "" + m.statuses["resources"] = "enabled" + m.loaded["io.went.agent-resources"] = true + if err := m.run(t); err != nil { + t.Fatal("approved retry failed", err) + } +} + +func TestManagedDrainGatePrecedesEveryMutation(t *testing.T) { + for _, managed := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy", true: "managed"}[managed], func(t *testing.T) { + m := managedTestFixture(t) + if managed { + if err := m.run(t); err != nil { + t.Fatal(err) + } + } else { + i := m.installer + for _, directory := range []string{i.state, i.launch} { + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + } + if err := i.writePlist(filepath.Join(i.launch, "io.went.agent-heavy.plist"), Object{"Label": "io.went.agent-heavy", "ProgramArguments": []string{filepath.Join(i.root, "bin/agent-heavy"), "daemon"}}); err != nil { + t.Fatal(err) + } + m.loaded["io.went.agent-heavy"] = true + } + m.tasks["41"] = Object{"status": Object{"Running": Object{}}} + before := installTreeWitness(t, m.installer.home) + m.calls = nil + if err := m.run(t); err == nil || !strings.Contains(err.Error(), "drained") { + t.Fatal("missing drain refusal", err) + } + if !reflect.DeepEqual(before, installTreeWitness(t, m.installer.home)) { + t.Fatal("drain refusal mutated installation") + } + for _, args := range m.calls { + if args[0] == "launchctl" && args[1] != "print" || len(args) > 2 && args[1] == "--service-management" && args[2] != "status" { + t.Fatal("drain refusal mutated services", args) + } + } + }) + } +} + +func TestManagedLegacyGoMigrationAndRetry(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + // Generate the canonical legacy definitions without installing any services. + for _, directory := range []string{i.launch, i.state, filepath.Join(i.root, "bin")} { + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + } + if err := i.validateServices(); err != nil { + t.Fatal(err) + } + if err := i.serviceDefinitions(false); err != nil { + t.Fatal(err) + } + for _, name := range []string{"agent-heavy", "agent-remote"} { + integrationTestWrite(t, filepath.Join(i.root, "bin", name), []byte("old helper")) + } + m.loaded["io.went.agent-heavy"] = true + m.loaded["io.went.agent-resources"] = true + m.loaded["io.went.agent-queue"] = true + m.nativeFailure = "register resources" + if err := m.run(t); err == nil { + t.Fatal("missing native failure") + } + if _, err := os.Stat(filepath.Join(i.root, "install-in-progress.json")); err != nil { + t.Fatal(err) + } + for _, label := range []string{"heavy", "resources", "queue"} { + if _, err := os.Lstat(filepath.Join(i.launch, "io.went.agent-"+label+".plist")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("retained legacy definition", label, err) + } + } + m.nativeFailure = "" + if err := m.run(t); err != nil { + t.Fatal("safe retry failed", err) + } + for _, name := range []string{"agent-heavy", "agent-remote"} { + if _, err := os.Lstat(filepath.Join(i.root, "bin", name)); !errors.Is(err, os.ErrNotExist) { + t.Fatal("retained hidden helper", err) + } + } +} + +func TestManagedRefusesUnownedAndCustomizedLegacyServices(t *testing.T) { + for _, kind := range []string{"missing-definition", "custom-env", "custom-option", "wrong-loaded-executable"} { + t.Run(kind, func(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + if err := os.MkdirAll(i.launch, 0700); err != nil { + t.Fatal(err) + } + definition := Object{"Label": "io.went.agent-heavy", "ProgramArguments": []string{filepath.Join(i.root, "bin/agent-heavy"), "daemon"}} + if kind == "custom-env" { + definition["EnvironmentVariables"] = Object{"AGENT_HEAVY_TEST_MODE": "1"} + } + if kind == "custom-option" { + definition["ProcessType"] = "Interactive" + } + if kind != "missing-definition" { + if err := i.writePlist(filepath.Join(i.launch, "io.went.agent-heavy.plist"), definition); err != nil { + t.Fatal(err) + } + } + m.loaded["io.went.agent-heavy"] = true + if kind == "wrong-loaded-executable" { + original := i.command + i.command = func(ctx context.Context, args []string) ([]byte, error) { + if slices.Equal(args, []string{"launchctl", "print", i.domain() + "/io.went.agent-heavy"}) { + return []byte("program = /unknown\narguments = {\n/unknown\n}\n"), nil + } + return original(ctx, args) + } + } + before := installTreeWitness(t, i.home) + if err := m.run(t); err == nil { + t.Fatal("unsafe migration accepted") + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("ownership refusal modified installation") + } + }) + } +} + +func TestManagedInvalidNativeJSONAndInstallationMetadata(t *testing.T) { + for _, kind := range []string{"json", "metadata", "marker", "signature"} { + t.Run(kind, func(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + if err := installCopyTree(filepath.Join(i.payload, "Agent Queue.app"), i.app); err != nil { + t.Fatal(err) + } + switch kind { + case "json": + m.badStatus = true + case "metadata": + integrationTestWrite(t, filepath.Join(i.root, "installation.json"), []byte(`{"schemaVersion":99,"app":"/unknown","serviceManagement":true}`)) + case "marker": + info, err := updatePlist(filepath.Join(i.payload, "Agent Queue.app/Contents/Info.plist")) + if err != nil { + t.Fatal(err) + } + info["AgentQueueServiceManagement"] = "true" + if err := i.writePlist(filepath.Join(i.payload, "Agent Queue.app/Contents/Info.plist"), info); err != nil { + t.Fatal(err) + } + case "signature": + m.fail = "codesign --verify" + } + before := installTreeWitness(t, i.home) + if err := m.run(t); err == nil { + t.Fatal("invalid managed install accepted") + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("invalid install mutated home") + } + }) + } +} + +func TestManagedReplacementRetainsPreviousUntilSuccessfulRetry(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + if err := m.run(t); err != nil { + t.Fatal(err) + } + previousDigest, err := managedBundleDigest(i.app) + if err != nil { + t.Fatal(err) + } + // Different signed bytes with the same development identity still require a + // drained service restart; signature verification is mocked by this fixture. + integrationTestWrite(t, filepath.Join(i.payload, "Agent Queue.app/Contents/Resources/new-resource"), []byte("new bundle bytes")) + m.nativeFailure = "register resources" + if err := m.run(t); err == nil { + t.Fatal("missing partial replacement failure") + } + backup := filepath.Join(filepath.Dir(i.app), ".Agent Queue.previous.app") + digest, err := managedBundleDigest(backup) + if err != nil || digest != previousDigest { + t.Fatal("known-good bundle lost", err) + } + m.nativeFailure = "" + if err := m.run(t); err != nil { + t.Fatal("replacement retry", err) + } + if _, err := os.Lstat(backup); !errors.Is(err, os.ErrNotExist) { + t.Fatal("completed retry did not retire backup", err) + } +} + +func TestManagedOfflineDrainReadsPrivateCopy(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + if err := os.MkdirAll(i.state, 0700); err != nil { + t.Fatal(err) + } + store, err := openQueueStore(i.state, true) + if err != nil { + t.Fatal(err) + } + store.close() + before := installTreeWitness(t, i.home) + if err := i.managedOfflineDrain(context.Background()); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("offline drain changed source database") + } + store, err = openQueueStore(i.state, false) + if err != nil { + t.Fatal(err) + } + _, err = store.db.Exec("INSERT INTO queue_jobs(id,token,task,phase) VALUES(41,'fixture','{}','running')") + store.close() + if err != nil { + t.Fatal(err) + } + if err := i.managedOfflineDrain(context.Background()); err == nil { + t.Fatal("offline unfinished attempt accepted") + } +} + +func TestManagedUnregistrationMustCompleteBeforeReplacement(t *testing.T) { + m := managedTestFixture(t) + if err := m.run(t); err != nil { + t.Fatal(err) + } + m.nativeFailure = "unregister queue" + before := installBundleSnapshot(t, m.installer.app) + if err := m.run(t); err == nil { + t.Fatal("unregistration failure accepted") + } + if !reflect.DeepEqual(before, installBundleSnapshot(t, m.installer.app)) { + t.Fatal("app replaced before unregister completion") + } +} + +func TestManagedInterruptedPublicationRestoresKnownGoodThenRetries(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + if err := m.run(t); err != nil { + t.Fatal(err) + } + old, err := os.ReadFile(filepath.Join(i.root, "build-info.json")) + if err != nil { + t.Fatal(err) + } + integrationTestWrite(t, filepath.Join(i.root, "install-in-progress.json"), old) + for _, service := range []string{"queue", "resources", "login"} { + m.statuses[service] = "notRegistered" + } + m.loaded = map[string]bool{} + backup := filepath.Join(filepath.Dir(i.app), ".Agent Queue.previous.app") + identity, err := managedCaptureBundle(i.app) + if err != nil { + t.Fatal(err) + } + receipt := managedPublicationReceipt{SchemaVersion: 1, App: i.app, Pending: identity.Build, Candidate: identity, Backup: &identity} + integrationTestWrite(t, i.managedReceiptPath(), mustMetadataJSON(t, receipt)) + if err := os.Rename(i.app, backup); err != nil { + t.Fatal(err) + } + if err := m.run(t); err != nil { + t.Fatal("interrupted publication could not recover", err) + } + if _, err := os.Stat(filepath.Join(i.app, "Contents/MacOS/AgentQueue")); err != nil { + t.Fatal(err) + } +} + +func TestManagedPendingIdentityRefusesDowngrade(t *testing.T) { + m := managedTestFixture(t) + m.nativeFailure = "register resources" + if err := m.run(t); err == nil { + t.Fatal("missing pending install") + } + i := m.installer + newer := metadataTestInfo(t, "124") + integrationTestWrite(t, filepath.Join(i.root, "install-in-progress.json"), mustMetadataJSON(t, newer)) + before := installTreeWitness(t, i.home) + if err := m.run(t); err == nil || !strings.Contains(err.Error(), "older") { + t.Fatal("pending identity did not block downgrade", err) + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("downgrade changed installation") + } +} + +func TestManagedReadinessRejectsWrongLoadedHelper(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + original := i.command + i.command = func(ctx context.Context, args []string) ([]byte, error) { + if m.statuses["queue"] == "enabled" && slices.Equal(args, []string{"launchctl", "print", i.domain() + "/io.went.agent-heavy"}) { + return []byte("program = /unknown\narguments = {\n/unknown\n}\n"), nil + } + return original(ctx, args) + } + if err := m.run(t); err == nil { + t.Fatal("foreign readiness accepted") + } + if _, err := os.Stat(filepath.Join(i.root, "install-in-progress.json")); err != nil { + t.Fatal("lost pending marker", err) + } +} + +func TestManagedCustomFinderLocationIsReadBackByUpdater(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + i.app = filepath.Join(i.home, "Applications/Finder Copied Queue.app") + if err := installCopyTree(filepath.Join(i.payload, "Agent Queue.app"), i.app); err != nil { + t.Fatal(err) + } + i.payload = filepath.Join(i.app, "Contents/Resources/payload") + i.appBundle = true + if err := m.run(t); err != nil { + t.Fatal(err) + } + app, err := updateRegisteredApp() + if err != nil || app != i.app { + t.Fatal("updater lost explicit app location", app, err) + } + version, err := updateInstalled(i.root, "") + if err != nil || version["build"] != 123 { + t.Fatal(version, err) + } +} + +func TestManagedPueuePythonMigrationPreservesImportAndRetiresRuntime(t *testing.T) { + m := managedTestFixture(t) + m.legacy(t) + i := m.installer + filename := filepath.Join(i.launch, "io.went.agent-resources.plist") + definition, err := updatePlist(filename) + if err != nil { + t.Fatal(err) + } + delete(definition, "EnvironmentVariables") // Only the fixture's custom setting is unsupported. + if err := i.writePlist(filename, definition); err != nil { + t.Fatal(err) + } + m.tasks["41"] = Object{"id": 41, "status": Object{"Done": Object{"result": "Success"}}} + if err := m.run(t); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(i.state, "legacy-queue-import.json")); err != nil { + t.Fatal("legacy import lost", err) + } + for _, name := range []string{"agent_heavy.py", ".venv"} { + if _, err := os.Lstat(filepath.Join(i.root, name)); !errors.Is(err, os.ErrNotExist) { + t.Fatal("legacy runtime retained", name, err) + } + } + node, err := installConfig(filepath.Join(i.config, "pueue.yml")) + if err != nil { + t.Fatal(err) + } + callback, err := installConfigCallback(node) + if err != nil || callback != installQuote(filepath.Join(i.home, ".local/bin/agent-heavy"))+" cleanup {{ id }}" { + t.Fatal(callback, err) + } +} + +func TestManagedExplicitPhoneRecoveryStillRequiresDrainedQueue(t *testing.T) { + m := managedTestFixture(t) + m.legacy(t) + i := m.installer + i.disablePhoneAccess = true + definition, err := updatePlist(filepath.Join(i.launch, "io.went.agent-resources.plist")) + if err != nil { + t.Fatal(err) + } + delete(definition, "EnvironmentVariables") + if err := i.writePlist(filepath.Join(i.launch, "io.went.agent-resources.plist"), definition); err != nil { + t.Fatal(err) + } + integrationTestWrite(t, filepath.Join(i.config, "remote.json"), []byte(`{"enabled":true}`)) + m.tasks["41"] = Object{"id": 41, "status": Object{"Running": Object{}}} + before := installTreeWitness(t, i.home) + if err := m.run(t); err == nil || !strings.Contains(err.Error(), "drained") { + t.Fatal("phone recovery bypassed drain", err) + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("phone recovery mutated active installation") + } + for _, args := range m.calls { + if len(args) > 1 && args[1] == "disable" { + t.Fatal("phone recovery ran before drain", args) + } + } +} + +func TestManagedRejectsEmbeddedExecutableCopiesAndWrongStaticArguments(t *testing.T) { + for _, kind := range []string{"bin", "arguments", "helper-symlink", "support-identity"} { + t.Run(kind, func(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + app := filepath.Join(i.payload, "Agent Queue.app") + switch kind { + case "bin": + integrationTestWrite(t, filepath.Join(app, "Contents/Resources/payload/source/bin/agent-heavy"), []byte("duplicate")) + case "arguments": + filename := filepath.Join(app, "Contents/Library/LaunchAgents/io.went.agent-heavy.plist") + value, err := updatePlist(filename) + if err != nil { + t.Fatal(err) + } + value["ProgramArguments"] = []string{"agent-heavy", "run", "unreviewed-command"} + if err := i.writePlist(filename, value); err != nil { + t.Fatal(err) + } + case "helper-symlink": + helper := filepath.Join(app, "Contents/MacOS/agent-heavy") + if err := os.Remove(helper); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(i.payload, "source/bin/agent-heavy"), helper); err != nil { + t.Fatal(err) + } + case "support-identity": + integrationTestWrite(t, filepath.Join(app, "Contents/Resources/payload/source/build-info.json"), mustMetadataJSON(t, metadataTestInfo(t, "124"))) + } + if err := m.run(t); err == nil { + t.Fatal("invalid managed layout accepted", kind) + } + if _, err := os.Lstat(i.root); !errors.Is(err, os.ErrNotExist) { + t.Fatal("invalid package started installation", err) + } + }) + } +} + +func TestManagedRetainedLegacyStateRequiresConfiguration(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + integrationTestWrite(t, filepath.Join(i.state, "pueue/state.json"), mustMetadataJSON(t, m.snapshot())) + before := installTreeWitness(t, i.home) + if err := m.run(t); err == nil || !strings.Contains(err.Error(), "configuration") { + t.Fatal("legacy custody gate missing", err) + } + if !reflect.DeepEqual(before, installTreeWitness(t, i.home)) { + t.Fatal("legacy custody refusal modified state") + } +} + +func TestManagedLegacyOwnershipIsRecheckedImmediatelyBeforeShutdown(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + for _, directory := range []string{i.launch, i.state} { + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + } + if err := i.writePlist(filepath.Join(i.launch, "io.went.agent-heavy.plist"), Object{"Label": "io.went.agent-heavy", "ProgramArguments": []string{filepath.Join(i.root, "bin/agent-heavy"), "daemon"}}); err != nil { + t.Fatal(err) + } + m.loaded["io.went.agent-heavy"] = true + original := i.command + i.command = func(ctx context.Context, args []string) ([]byte, error) { + if slices.Equal(args, []string{"launchctl", "print", i.domain() + "/io.went.agent-heavy"}) { + if _, err := os.Stat(filepath.Join(i.root, "install-in-progress.json")); err == nil { + return []byte("program = /unknown\narguments = {\n/unknown\n}\n"), nil + } + } + return original(ctx, args) + } + if err := m.run(t); err == nil { + t.Fatal("changed loaded ownership accepted") + } + for _, args := range m.calls { + if len(args) > 1 && args[0] == "launchctl" && args[1] == "bootout" { + t.Fatal("stopped changed process", args) + } + } +} + +func TestManagedFinderTargetMigratesOldCanonicalMenuLocation(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + if err := os.MkdirAll(i.launch, 0700); err != nil { + t.Fatal(err) + } + if err := i.validateServices(); err != nil { + t.Fatal(err) + } + if err := i.serviceDefinitions(false); err != nil { + t.Fatal(err) + } + i.app = filepath.Join(i.home, "Applications/Finder Copied Queue.app") + if err := installCopyTree(filepath.Join(i.payload, "Agent Queue.app"), i.app); err != nil { + t.Fatal(err) + } + i.payload = filepath.Join(i.app, "Contents/Resources/payload") + i.appBundle = true + if err := m.run(t); err != nil { + t.Fatal("explicit Finder target failed to adopt canonical old menu", err) + } + if _, err := os.Lstat(filepath.Join(i.launch, "io.went.agent-queue.plist")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("old menu was retained", err) + } +} diff --git a/internal/backend/install_preflight.go b/internal/backend/install_preflight.go index ec546b1..a30ba2f 100644 --- a/internal/backend/install_preflight.go +++ b/internal/backend/install_preflight.go @@ -54,6 +54,8 @@ type installOptions struct { target []string preflight bool disablePhoneAccess bool + prepareReplacement bool + unregisterServices bool recovery string jobID *int revision string @@ -61,8 +63,8 @@ type installOptions struct { func installParseOptions(args []string) (installOptions, error) { options := installOptions{} - usage := errors.New("Usage: agent-heavy install (--payload DIRECTORY | --app-bundle ABSOLUTE_APP_PATH) [--preflight --json | --recover ACTION [--job-id ID] [--expected-revision SHA] | --disable-phone-access]") - if len(args) < 2 || (args[0] != "--payload" && args[0] != "--app-bundle") || !filepath.IsAbs(args[1]) { + usage := errors.New("Usage: agent-heavy install (--payload ABSOLUTE_PAYLOAD | --app-bundle ABSOLUTE_APP_PATH) [--preflight --json | --recover ACTION [--job-id ID] [--expected-revision SHA] | --disable-phone-access | --prepare-replacement (payload only) | --unregister-services (app-bundle only)]") + if len(args) < 2 || (args[0] != "--payload" && args[0] != "--app-bundle") || !integrationValidPath(args[1]) || filepath.Clean(args[1]) != args[1] { return options, usage } options.target = append([]string{}, args[:2]...) @@ -79,6 +81,10 @@ func installParseOptions(args []string) (installOptions, error) { case "--json": case "--disable-phone-access": options.disablePhoneAccess = true + case "--prepare-replacement": + options.prepareReplacement = true + case "--unregister-services": + options.unregisterServices = true case "--recover", "--job-id", "--expected-revision": if i+1 >= len(args) { return options, usage @@ -104,6 +110,17 @@ func installParseOptions(args []string) (installOptions, error) { return options, usage } } + if options.prepareReplacement || options.unregisterServices { + if len(args) != 3 || options.prepareReplacement && args[0] != "--payload" || options.unregisterServices && args[0] != "--app-bundle" { + return options, usage + } + if options.unregisterServices { + home, err := os.UserHomeDir() + if err != nil || installValidateAppPath(home, args[1]) != nil { + return options, usage + } + } + } if options.preflight != seen["--json"] || options.preflight && (options.recovery != "" || options.disablePhoneAccess) || options.recovery != "" && options.disablePhoneAccess || options.recovery == "" && (options.jobID != nil || options.revision != "") { return options, usage } @@ -347,6 +364,10 @@ func (install *installer) preflight(ctx context.Context) installPreflightReport report.add("platform-unsupported", "macOS is required", "Agent Queue installation requires macOS.", "") return report } + if err := install.resolvePayloadApp(); err != nil { + report.add("installation-invalid", "Review the recorded application location", err.Error(), install.root, "reveal") + return report + } if err := install.validatePayload(); err != nil { actions := []string{"download-release", "reveal"} if install.appBundle && installValidateAppPath(install.home, install.app) != nil { @@ -354,6 +375,9 @@ func (install *installer) preflight(ctx context.Context) installPreflightReport } report.add("payload-invalid", "Review the application package", err.Error(), install.app, actions...) } + if install.managed { + return install.preflightManaged(ctx, report) + } privateSafe := true for _, directory := range []string{install.root, filepath.Join(install.root, "bin"), install.state, filepath.Join(install.state, "pueue"), install.config, install.launch, filepath.Join(install.home, ".local/bin")} { if err := installInspectDirectory(directory); err != nil { diff --git a/internal/backend/install_private_services.go b/internal/backend/install_private_services.go new file mode 100644 index 0000000..f85cd69 --- /dev/null +++ b/internal/backend/install_private_services.go @@ -0,0 +1,67 @@ +package backend + +import ( + "errors" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/Dunky13/agent-job-queue/internal/appservices" + "golang.org/x/sys/unix" +) + +// A private HOME does not isolate Service Management's login domain. Artifact +// acceptance must explicitly opt into a private parent-owned lifecycle tool; +// otherwise test-mode native commands fail before reaching macOS registration. +// Signature checks and actual packaged daemon/job execution remain unchanged. +func (install *installer) privateManagedCommand(args []string) ([]string, error) { + refused := errors.New("Managed installer tests require the private native lifecycle fixture; real service management is disabled in test mode") + if !install.testing || os.Getenv("AGENT_QUEUE_INSTALL_TEST_MANAGED") != "private-native-lifecycle" || len(args) < 3 { + return nil, refused + } + app := filepath.Dir(filepath.Dir(filepath.Dir(args[0]))) + if args[0] != filepath.Join(app, "Contents/MacOS/AgentQueue") || app != install.app && app != filepath.Join(install.payload, "Agent Queue.app") { + return nil, refused + } + var toolArgs []string + if args[1] == "--prepare-app-replacement" { + if len(args) != 3 || args[2] != install.app { + return nil, refused + } + toolArgs = []string{"prepare-app-replacement", args[2]} + } else { + if len(args) != 4 || args[1] != "--service-management" { + return nil, refused + } + expected, err := appservices.Arguments(app, args[2], args[3]) + if err != nil || !slices.Equal(expected, args) { + return nil, refused + } + toolArgs = args[2:] + } + root := filepath.Dir(install.home) + tools := filepath.Join(root, "tools") + if filepath.Base(install.home) != "User With Spaces" || !strings.HasPrefix(filepath.Base(root), "agent-queue-install-test-") || os.Getenv("AGENT_QUEUE_INSTALL_TEST_TOOLS") != tools { + return nil, refused + } + canonical, err := filepath.EvalSymlinks(root) + if err != nil || canonical != root { + return nil, refused + } + for _, directory := range []string{root, install.home, tools} { + var info unix.Stat_t + if unix.Lstat(directory, &info) != nil || info.Mode&unix.S_IFMT != unix.S_IFDIR || info.Mode&0777 != 0700 || info.Uid != uint32(os.Getuid()) { + return nil, refused + } + } + tool := filepath.Join(tools, "service-management") + var executable, socket unix.Stat_t + if unix.Lstat(tool, &executable) != nil || executable.Mode&unix.S_IFMT != unix.S_IFREG || executable.Mode&0077 != 0 || executable.Mode&0100 == 0 || executable.Uid != uint32(os.Getuid()) || executable.Nlink != 1 { + return nil, refused + } + if unix.Lstat(filepath.Join(root, "native.sock"), &socket) != nil || socket.Mode&unix.S_IFMT != unix.S_IFSOCK || socket.Mode&0077 != 0 || socket.Uid != uint32(os.Getuid()) { + return nil, refused + } + return append([]string{tool}, toolArgs...), nil +} diff --git a/internal/backend/install_private_services_test.go b/internal/backend/install_private_services_test.go new file mode 100644 index 0000000..7894fe1 --- /dev/null +++ b/internal/backend/install_private_services_test.go @@ -0,0 +1,122 @@ +package backend + +import ( + "context" + "net" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestManagedPrivateNativeCommandsCannotReachRealServices(t *testing.T) { + // Unix-domain socket paths on macOS cannot fit Go's long test-name roots. + root, err := os.MkdirTemp("/tmp", "agent-queue-install-test-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + root, err = filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + home, tools := filepath.Join(root, "User With Spaces"), filepath.Join(root, "tools") + for _, directory := range []string{home, tools} { + if err := os.Mkdir(directory, 0700); err != nil { + t.Fatal(err) + } + } + i := &installer{home: home, app: filepath.Join(home, "Applications/Agent Queue.app"), state: filepath.Join(home, ".local/state/agent-heavy"), config: filepath.Join(home, ".config/agent-heavy"), path: tools, testing: true} + args := []string{filepath.Join(i.app, "Contents/MacOS/AgentQueue"), "--service-management", "status", "queue"} + // Even before a native executable exists, missing explicit fixture custody + // must produce the isolation error rather than attempting that executable. + t.Setenv("AGENT_QUEUE_INSTALL_TEST_MANAGED", "") + if _, err := i.run(context.Background(), args...); err == nil || !strings.Contains(err.Error(), "real service management is disabled") { + t.Fatal("test mode could reach the real native path", err) + } + t.Setenv("AGENT_QUEUE_INSTALL_TEST_MANAGED", "private-native-lifecycle") + t.Setenv("AGENT_QUEUE_INSTALL_TEST_TOOLS", tools) + t.Setenv("AGENT_HEAVY_STATE", "/foreign/queue") + tool := filepath.Join(tools, "service-management") + if err := os.WriteFile(tool, []byte("#!/bin/sh\nprintf '%s\\n' \"$1\" \"$2\" \"$HOME\" \"$AGENT_HEAVY_STATE\"\n"), 0700); err != nil { + t.Fatal(err) + } + socket := filepath.Join(root, "native.sock") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + if err := os.Chmod(socket, 0600); err != nil { + t.Fatal(err) + } + output, err := i.run(context.Background(), args...) + if want := strings.Join([]string{"status", "queue", home, i.state, ""}, "\n"); err != nil || string(output) != want { + t.Fatalf("private lifecycle tool lost argument or environment isolation: %q: %v", output, err) + } + replacement := []string{args[0], "--prepare-app-replacement", i.app} + output, err = i.run(context.Background(), replacement...) + if want := strings.Join([]string{"prepare-app-replacement", i.app, home, i.state, ""}, "\n"); err != nil || string(output) != want { + t.Fatalf("private UI shutdown tool lost argument or environment isolation: %q: %v", output, err) + } + if _, err := i.privateManagedCommand([]string{args[0], "--prepare-app-replacement", filepath.Join(home, "Other.app")}); err == nil { + t.Fatal("private fixture accepted another app's UI shutdown") + } + for _, target := range []string{root, home, tools, tool, socket} { + info, err := os.Lstat(target) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(target, info.Mode().Perm()|0020); err != nil { + t.Fatal(err) + } + _, commandErr := i.privateManagedCommand(args) + if err := os.Chmod(target, info.Mode().Perm()); err != nil { + t.Fatal(err) + } + if commandErr == nil { + t.Fatal("accepted unsafe fixture custody", target) + } + } + for _, change := range [][]string{ + {filepath.Join(home, "Another.app/Contents/MacOS/AgentQueue"), args[1], args[2], args[3]}, + {args[0], args[1], "unknown", args[3]}, + {args[0], args[1], args[2], "unknown"}, + append(slices.Clone(args), "extra"), + } { + if _, err := i.privateManagedCommand(change); err == nil { + t.Fatal("accepted foreign native command", change) + } + } +} + +func TestManagedInstallerOpensAppOnlyAfterDurableCompletion(t *testing.T) { + m := managedTestFixture(t) + i := m.installer + i.testing = false + original := i.command + opened := false + i.command = func(ctx context.Context, args []string) ([]byte, error) { + if args[0] == "/usr/bin/ditto" { + return nil, installCopyTree(args[len(args)-2], args[len(args)-1]) + } + if args[0] == "/usr/bin/open" { + if !slices.Equal(args, []string{"/usr/bin/open", "-a", i.app}) { + t.Fatal("opened an unrelated application", args) + } + if _, err := os.Stat(filepath.Join(i.root, "install-in-progress.json")); !os.IsNotExist(err) { + t.Fatal("opened ordinary app before setup completion", err) + } + if _, err := updateInstalled(i.root, i.app); err != nil { + t.Fatal("opened app without completed matching identity", err) + } + opened = true + return nil, nil + } + return original(ctx, args) + } + if err := m.run(t); err != nil || !opened { + t.Fatal("explicit managed install did not open the ready app", err) + } +} diff --git a/internal/backend/install_test.go b/internal/backend/install_test.go index b5b91ac..dc82995 100644 --- a/internal/backend/install_test.go +++ b/internal/backend/install_test.go @@ -2119,17 +2119,45 @@ func installTestPackaged(t *testing.T, appBundle bool) { if payload != "" && !filepath.IsAbs(payload) { t.Fatal("release payload must be absolute") } - f := installTestFixture(t) + markedApp := appSource + if markedApp == "" || !appBundle { + markedApp = filepath.Join(payload, "Agent Queue.app") + } + metadata, err := updatePlist(filepath.Join(markedApp, "Contents/Info.plist")) + if err != nil { + t.Fatal(err) + } + managed, _ := metadata["AgentQueueServiceManagement"].(bool) + var f *installFixture + var managedRuntime *managedFixture + if managed { + managedRuntime = managedTestFixture(t) + f = managedRuntime.installFixture + } else { + f = installTestFixture(t) + } install := f.installer - home, err := os.MkdirTemp("/tmp", "aq-pkg-") + base, err := os.MkdirTemp("/tmp", "aq-pkg-") if err != nil { t.Fatal(err) } - home, err = filepath.EvalSymlinks(home) + base, err = filepath.EvalSymlinks(base) if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = os.RemoveAll(home) }) + home := filepath.Join(base, "User With Spaces") + if err := os.Mkdir(home, 0700); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if t.Failed() { + t.Logf("packaged runtime failure evidence retained at %s", base) + return + } + if err := os.RemoveAll(base); err != nil { + t.Error(err) + } + }) t.Setenv("HOME", home) install.home = home install.payload = payload @@ -2159,7 +2187,18 @@ func installTestPackaged(t *testing.T, appBundle bool) { install.payload = filepath.Join(install.app, "Contents/Resources/payload") appBefore = installBundleSnapshot(t, install.app) } - if err := f.run(t); err != nil { + if managed { + managedPackagedRuntimeFixture(t, managedRuntime) + } + // Packaged setup now owns actual daemon startup, previously performed later + // under its own readiness deadline. The five-second synthetic fixture runner + // cannot include cold executable policy checks, bundle copying and startup. + runPackaged := func() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return install.execute(ctx) + } + if err := runPackaged(); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(install.config, "pueue.yml")); !errors.Is(err, os.ErrNotExist) { @@ -2167,7 +2206,7 @@ func installTestPackaged(t *testing.T, appBundle bool) { } private := filepath.Join(install.state, "fixture-history") integrationTestWrite(t, private, []byte("preserved private fixture")) - if err := f.run(t); err != nil { + if err := runPackaged(); err != nil { t.Fatal(err) } if string(integrationTestRead(t, private)) != "preserved private fixture" { @@ -2231,7 +2270,9 @@ func installTestPackaged(t *testing.T, appBundle bool) { return command } cli := filepath.Join(home, ".local/bin/agent-heavy") - start("daemon", cli, "daemon") + if !managed { + start("daemon", cli, "daemon") + } deadline := time.Now().Add(10 * time.Second) ready := false for time.Now().Before(deadline) { @@ -2255,14 +2296,16 @@ func installTestPackaged(t *testing.T, appBundle bool) { if appBundle { // Re-run setup with the real packaged daemon live, then exercise a job // through that same daemon and scheduler below. - if err := f.run(t); err != nil { + if err := runPackaged(); err != nil { t.Fatalf("packaged app setup could not retry with a live daemon: %v", err) } } if _, err := os.Stat(filepath.Join(install.config, "pueue.yml")); !errors.Is(err, os.ErrNotExist) { t.Fatal("packaged upgrade or daemon generated legacy queue configuration") } - start("scheduler", cli, "scheduler") + if !managed { + start("scheduler", cli, "scheduler") + } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() job := exec.CommandContext(ctx, cli, "run", "--label", "Packaged fixture", "--", "/bin/sh", "-c", `printf '%s|%s|%s\n' "$1" "$2" "$PACKAGED_TEST_VALUE"; exit 7`, "packaged-fixture", "argument with spaces", "literal;$HOME") diff --git a/internal/backend/managed_runtime_fixture_test.go b/internal/backend/managed_runtime_fixture_test.go new file mode 100644 index 0000000..8b695bf --- /dev/null +++ b/internal/backend/managed_runtime_fixture_test.go @@ -0,0 +1,116 @@ +package backend + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "slices" + "syscall" + "testing" + "time" +) + +// Only native lifecycle and OS ownership readback are simulated. Readiness and +// drain snapshots come from the actual packaged canonical helper and private DB. +// The parent test owns all processes, independently of installer command scopes. +func managedPackagedRuntimeFixture(t *testing.T, m *managedFixture) { + t.Helper() + i := m.installer + type process struct { + command *exec.Cmd + done chan error + log *os.File + } + processes := map[string]*process{} + stop := func(service string) { + t.Helper() + p := processes[service] + if p == nil { + return + } + delete(processes, service) + _ = p.command.Process.Signal(syscall.SIGTERM) + select { + case <-p.done: + case <-time.After(5 * time.Second): + _ = p.command.Process.Kill() + <-p.done + t.Errorf("packaged %s required forced teardown", service) + } + if err := p.log.Close(); err != nil { + t.Error(err) + } + if err := p.command.Process.Signal(syscall.Signal(0)); !errors.Is(err, os.ErrProcessDone) { + t.Errorf("packaged %s was not reaped: %v", service, err) + } + } + t.Cleanup(func() { stop("resources"); stop("queue") }) + start := func(service string) error { + if processes[service] != nil { + return nil + } + name, argument := "daemon", "daemon" + if service == "resources" { + name, argument = "scheduler", "scheduler" + } + log, err := os.Create(filepath.Join(i.home, name+".log")) + if err != nil { + return err + } + command := exec.Command(filepath.Join(i.app, "Contents/MacOS/agent-heavy"), argument) + command.Env = i.commandEnvironment([]string{filepath.Join(i.app, "Contents/MacOS/agent-heavy"), "status", "--json"}) + command.Stdout, command.Stderr = log, log + if err := command.Start(); err != nil { + log.Close() + return err + } + p := &process{command: command, done: make(chan error, 1), log: log} + processes[service] = p + go func() { p.done <- command.Wait() }() + return nil + } + heavy := filepath.Join(i.app, "Contents/MacOS/agent-heavy") + status := func(ctx context.Context) ([]byte, error) { + command := exec.CommandContext(ctx, heavy, "status", "--json") + command.Env = i.commandEnvironment([]string{heavy, "status", "--json"}) + output, err := command.CombinedOutput() + witnessErr := os.WriteFile(filepath.Join(i.home, "readiness.json"), output, 0600) + return output, errors.Join(err, witnessErr) + } + // Use the production private RPC snapshot, not the fixture's synthetic tasks + // or the public status CLI's different JSON schema. + i.queueSnapshot = func(ctx context.Context) (Object, error) { + deadline := time.Now().Add(15 * time.Second) + if limit, ok := ctx.Deadline(); ok && limit.Before(deadline) { + deadline = limit + } + gate := &Gate{State: i.state, Config: filepath.Join(i.config, "pueue.yml"), + ControlEnv: map[string]string{"HOME": i.home, "AGENT_HEAVY_STATE": i.state}, ControlDeadline: deadline} + return gate.Snapshot() + } + original := i.command + i.command = func(ctx context.Context, args []string) ([]byte, error) { + if len(args) == 3 && args[1] == "--prepare-app-replacement" && args[2] == i.app && + (args[0] == filepath.Join(i.app, "Contents/MacOS/AgentQueue") || args[0] == filepath.Join(i.payload, "Agent Queue.app/Contents/MacOS/AgentQueue")) { + m.calls = append(m.calls, slices.Clone(args)) + return mustMetadataJSON(t, Object{"schemaVersion": 1, "app": i.app, "status": "stopped"}), nil + } + if slices.Equal(args, []string{heavy, "status", "--json"}) { + m.calls = append(m.calls, slices.Clone(args)) + return status(ctx) + } + if len(args) == 4 && args[1] == "--service-management" && (args[3] == "queue" || args[3] == "resources") { + if args[2] == "unregister" { + stop(args[3]) + } + output, err := original(ctx, args) + if err == nil && args[2] == "register" && m.statuses[args[3]] == "enabled" { + err = start(args[3]) + } + return output, err + } + return original(ctx, args) + } +} diff --git a/internal/backend/queue_daemon.go b/internal/backend/queue_daemon.go index 1f2450e..40c47b0 100644 --- a/internal/backend/queue_daemon.go +++ b/internal/backend/queue_daemon.go @@ -54,6 +54,10 @@ func safeQueueSocket(path string) error { return nil } func (g *Gate) QueueControl(operation string, payload Object) (Object, error) { + ctx := g.operationContext() + if err := ctx.Err(); err != nil { + return nil, err + } if g.queueControl != nil { return g.queueControl(operation, payload) } @@ -67,11 +71,14 @@ func (g *Gate) QueueControl(operation string, payload Object) (Object, error) { if !deadline.After(time.Now()) { return nil, errors.New("Queue control deadline expired") } - conn, e := net.DialTimeout("unix", queueSocket(g), time.Until(deadline)) + dialer := net.Dialer{Deadline: deadline} + conn, e := dialer.DialContext(ctx, "unix", queueSocket(g)) if e != nil { return nil, errors.New("Queue daemon unavailable; command was not run directly") } defer conn.Close() + stopCancellation := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stopCancellation() if e = conn.SetDeadline(deadline); e != nil { return nil, e } diff --git a/internal/backend/scheduler_controller.go b/internal/backend/scheduler_controller.go index cde6b11..480e05f 100644 --- a/internal/backend/scheduler_controller.go +++ b/internal/backend/scheduler_controller.go @@ -1,6 +1,7 @@ package backend import ( + "context" "fmt" "os" "os/signal" @@ -645,6 +646,13 @@ func (c *Controller) CensorRuns() error { func (c *Controller) Close() { c.store.Close() } func ServeScheduler(g *Gate, once bool) error { return serveScheduler(g, nil, once) } func serveScheduler(g *Gate, sampler *ResourceSampler, once bool) error { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + // The installer retains scheduling.lock until helper shutdown completes. + // Interrupt this controller's waits without weakening that shared lock. + controlledGate := *g + controlledGate.controlContext = ctx + g = &controlledGate if e := g.Prepare(); e != nil { return e } @@ -658,12 +666,12 @@ func serveScheduler(g *Gate, sampler *ResourceSampler, once bool) error { return e } defer controller.Close() - signals := make(chan os.Signal, 1) - signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) - defer signal.Stop(signals) for { started := time.Now() _, e = controller.Tick() + if ctx.Err() != nil { + return nil + } if e != nil { if censorError := controller.CensorRuns(); censorError != nil { return censorError @@ -689,7 +697,7 @@ func serveScheduler(g *Gate, sampler *ResourceSampler, once bool) error { } timer := time.NewTimer(delay) select { - case <-signals: + case <-ctx.Done(): timer.Stop() return nil case <-timer.C: diff --git a/internal/backend/scheduler_shutdown_test.go b/internal/backend/scheduler_shutdown_test.go new file mode 100644 index 0000000..49fe8fb --- /dev/null +++ b/internal/backend/scheduler_shutdown_test.go @@ -0,0 +1,173 @@ +package backend + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestSchedulerShutdownWhileInstallerHoldsSchedulingLock(t *testing.T) { + const childState = "AGENT_QUEUE_TEST_SCHEDULER_SHUTDOWN_STATE" + state := os.Getenv(childState) + if state != "" { + g := &Gate{State: state, Slot: filepath.Join(state, "slot"), Config: filepath.Join(state, "queue.json"), + Jobs: filepath.Join(state, "jobs"), Receipts: filepath.Join(state, "receipts"), + Metadata: filepath.Join(state, "metadata"), Cancelled: filepath.Join(state, "cancelled")} + g.queueControl = func(string, Object) (Object, error) { + if err := os.WriteFile(filepath.Join(state, "snapshot-ready"), []byte("ready"), 0600); err != nil { + return nil, err + } + return Object{"tasks": Object{}, "groups": Object{"default": Object{"parallel_tasks": 1, "status": "Running"}}}, nil + } + sampler := NewResourceSampler(state, &admissionFixtureBackend{pressure: "normal", processCount: map[int]uint64{}}) + if err := serveScheduler(g, sampler, false); err != nil { + t.Fatal(err) + } + return + } + physical, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + state = physical + g := &Gate{State: state} + if err := WritePrivate(filepath.Join(state, "scheduling.json"), Object{"mode": "fixed", "generation": "shutdown-fixture"}); err != nil { + t.Fatal(err) + } + unlock, err := LockPrivate(g, "scheduling.lock") + if err != nil { + t.Fatal(err) + } + defer unlock() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + command := exec.Command(executable, "-test.run=^TestSchedulerShutdownWhileInstallerHoldsSchedulingLock$") + command.Env = append(os.Environ(), childState+"="+state) + var output bytes.Buffer + command.Stdout, command.Stderr = &output, &output + if err := command.Start(); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + var waitError error + go func() { waitError = command.Wait(); close(done) }() + t.Cleanup(func() { + select { + case <-done: + default: + _ = command.Process.Kill() + <-done + } + }) + fixtureUntil(t, 5*time.Second, func() bool { + _, err := os.Stat(filepath.Join(state, "snapshot-ready")) + return err == nil + }) + if err := command.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + select { + case <-done: + if waitError != nil { + t.Fatalf("scheduler did not shut down gracefully: %v: %s", waitError, output.String()) + } + if busy, err := installInspectLock(state, "resource-controller.lock"); err != nil || busy { + t.Fatal("scheduler retained its lifetime lock", busy, err) + } + if busy, err := installInspectLock(state, "scheduling.lock"); err != nil || !busy { + t.Fatal("scheduler shutdown changed installer lock ownership", busy, err) + } + case <-time.After(3 * time.Second): + t.Fatal("scheduler ignored termination while waiting for installer-owned scheduling lock") + } +} + +func TestQueueControlCancellationClosesStalledPrivateConnection(t *testing.T) { + state, err := os.MkdirTemp("/tmp", "aq-rpc-cancel-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(state) }) + if err := os.Mkdir(filepath.Join(state, "run"), 0700); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + g := &Gate{State: state, controlContext: ctx} + listener, err := net.Listen("unix", queueSocket(g)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + if err := os.Chmod(queueSocket(g), 0600); err != nil { + t.Fatal(err) + } + received, serverDone := make(chan struct{}), make(chan struct{}) + var serverError error + go func() { + defer close(serverDone) + connection, err := listener.Accept() + if err != nil { + serverError = err + return + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(5 * time.Second)) + decoder := json.NewDecoder(connection) + var request queueRequest + if err := decoder.Decode(&request); err != nil { + serverError = err + return + } + close(received) + var next queueRequest + serverError = decoder.Decode(&next) + }() + t.Cleanup(func() { + cancel() + _ = listener.Close() + select { + case <-serverDone: + case <-time.After(6 * time.Second): + t.Error("private RPC fixture did not stop") + } + }) + done := make(chan error, 1) + go func() { _, err := g.QueueControl("snapshot", nil); done <- err }() + select { + case <-received: + case <-time.After(3 * time.Second): + t.Fatal("private RPC did not reach its controlled stalled response") + } + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("cancelled RPC returned success") + } + case <-time.After(time.Second): + t.Fatal("cancelled private RPC waited for its normal deadline") + } + select { + case <-serverDone: + if !errors.Is(serverError, io.EOF) { + t.Fatal("cancellation did not close its owned socket", serverError) + } + case <-time.After(time.Second): + t.Fatal("cancelled RPC retained its private connection") + } + if _, err := g.QueueControl("snapshot", nil); !errors.Is(err, context.Canceled) { + t.Fatal("already cancelled control reached I/O", err) + } +} diff --git a/internal/backend/updates.go b/internal/backend/updates.go index 72e3c8c..a630a58 100644 --- a/internal/backend/updates.go +++ b/internal/backend/updates.go @@ -23,6 +23,7 @@ import ( "time" "unicode" + "github.com/Dunky13/agent-job-queue/internal/appservices" "golang.org/x/sys/unix" "golang.org/x/text/cases" "golang.org/x/text/unicode/norm" @@ -40,34 +41,7 @@ var updateDigest = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) var updateDecimal = regexp.MustCompile(`^[0-9]+$`) func updateReadOwned(path string, limit int64) ([]byte, error) { - fd, err := unix.Open(path, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) - if err != nil { - return nil, err - } - file := os.NewFile(uintptr(fd), path) - defer file.Close() - var info unix.Stat_t - if err := unix.Fstat(fd, &info); err != nil { - return nil, err - } - if uint32(info.Mode)&unix.S_IFMT != unix.S_IFREG || info.Uid != uint32(os.Getuid()) || uint32(info.Mode)&0022 != 0 || info.Nlink != 1 { - return nil, errors.New("Installed version metadata has unsafe ownership or permissions") - } - before := integrationStatIdentity(&info) - data, err := io.ReadAll(io.LimitReader(file, limit+1)) - if err != nil { - return nil, err - } - if int64(len(data)) > limit { - return nil, errors.New("Installed version metadata exceeds its size limit") - } - if err := unix.Fstat(fd, &info); err != nil { - return nil, err - } - if before != integrationStatIdentity(&info) { - return nil, errors.New("Installed version metadata changed during inspection") - } - return data, nil + return appservices.ReadFile(filepath.Dir(path), path, limit, false) } func updateExists(path string) (bool, error) { @@ -148,6 +122,17 @@ func updateInstalled(root, app string) (Object, error) { func updateRegisteredApp() (string, error) { home := integrationHome() + if managed, err := appservices.Load(home); err != nil { + return "", err + } else if managed != nil { + if err := updateCompleted(filepath.Join(home, ".local/share/agent-heavy")); err != nil { + return "", err + } + if err := appservices.ValidateApp(home, managed.App); err != nil { + return "", err + } + return managed.App, nil + } fallback := filepath.Join(home, "Applications/Agent Queue.app") filename := filepath.Join(home, "Library/LaunchAgents/io.went.agent-queue.plist") parent, err := integrationParent(filename, false) @@ -856,7 +841,7 @@ func (client *UpdateClient) OpenInstaller(build int) (Object, error) { return nil, err } temporary = "" - return Object{"installerOpened": true, "build": build, "message": "Installer opened. Choose Install to finish. Queued and running jobs are preserved"}, nil + return Object{"installerOpened": true, "build": build, "message": "Installer opened. Finish queued and running jobs. Choose Install to continue; active work blocks managed service changes"}, nil } func UpdatesMain(args []string) int { diff --git a/internal/remote/control.go b/internal/remote/control.go index 5239c23..6e50d2d 100644 --- a/internal/remote/control.go +++ b/internal/remote/control.go @@ -27,6 +27,7 @@ import ( "time" "unicode/utf8" + "github.com/Dunky13/agent-job-queue/internal/appservices" "github.com/caddyserver/caddy/v2" "golang.org/x/crypto/bcrypt" ) @@ -53,6 +54,7 @@ type Settings struct { type remotePaths struct { testing bool state, config, plist, socket, pid, gatewayConfig, certificate, log, errorFile, children string + home, managedApp, managedCandidate string } func newPaths() (remotePaths, error) { @@ -60,7 +62,7 @@ func newPaths() (remotePaths, error) { if err != nil { return remotePaths{}, err } - p := remotePaths{testing: os.Getenv("AGENT_REMOTE_TEST_MODE") == "1"} + p := remotePaths{testing: os.Getenv("AGENT_REMOTE_TEST_MODE") == "1", home: home} p.state = filepath.Join(home, ".local/state/agent-heavy/remote") p.config = filepath.Join(home, ".config/agent-heavy/remote.json") p.plist = filepath.Join(home, "Library/LaunchAgents", label+".plist") @@ -86,6 +88,20 @@ func newPaths() (remotePaths, error) { p.log = filepath.Join(p.state, "service.log") p.errorFile = filepath.Join(p.state, "error.json") p.children = filepath.Join(p.state, "children") + if !p.testing { + installed, err := appservices.Load(home) + if err != nil { + return remotePaths{}, fmt.Errorf("Cannot read managed phone service ownership: %w", err) + } + if installed != nil { + p.managedApp = installed.App + } + executable, err := os.Executable() + if err != nil { + return remotePaths{}, err + } + p.managedCandidate, _ = appservices.BundleForExecutable(executable) + } return p, nil } @@ -247,15 +263,18 @@ type childRecord struct { } type controller struct { - p remotePaths - ctx context.Context - birthFn func(int) (string, error) - bootFn func() (string, error) - groupFn func(int) (int, error) - killFn func(int, syscall.Signal) error - killGroupFn func(int, syscall.Signal) error - serviceFn func(string) error - reconcileFn func(string) error + p remotePaths + ctx context.Context + birthFn func(int) (string, error) + bootFn func() (string, error) + groupFn func(int) (int, error) + killFn func(int, syscall.Signal) error + killGroupFn func(int, syscall.Signal) error + serviceFn func(string) error + reconcileFn func(string) error + managedCommand func([]string) ([]byte, error) + newCommand func(context.Context, string, ...string) *exec.Cmd + installMigration bool } func newController(p remotePaths, ctx context.Context) *controller { @@ -287,7 +306,11 @@ func controlEnvironment(testing bool) []string { func (c *controller) command(arguments []string, input []byte, timeout time.Duration, check bool) ([]byte, error) { ctx, cancel := context.WithTimeout(c.ctx, timeout) defer cancel() - cmd := exec.CommandContext(ctx, arguments[0], arguments[1:]...) + newCommand := c.newCommand + if newCommand == nil { + newCommand = exec.CommandContext + } + cmd := newCommand(ctx, arguments[0], arguments[1:]...) cmd.WaitDelay = time.Second cmd.Env = controlEnvironment(false) if input != nil { @@ -673,6 +696,12 @@ func xmlText(value string) string { return buffer.String() } func (c *controller) launchAgent() error { + if c.p.managedApp != "" { + return nil // The signed app owns its service definition. + } + if c.p.managedCandidate != "" { + return errors.New("Complete Agent Queue installation before enabling phone access") + } if err := os.MkdirAll(filepath.Dir(c.p.plist), 0700); err != nil { return err } @@ -691,6 +720,9 @@ func (c *controller) launchAgent() error { } func (c *controller) serviceAction(action string) error { + if c.p.managedApp != "" { + return c.managedServiceAction(action) + } if c.p.testing { if action != "stop" { return nil @@ -728,6 +760,29 @@ func (c *controller) serviceAction(action string) error { } func (c *controller) enable() error { + if c.p.managedCandidate != "" && c.p.managedApp == "" { + return errors.New("Complete Agent Queue installation before enabling phone access") + } + if c.p.managedApp != "" { + if !c.installMigration { + pending := filepath.Join(c.p.home, ".local/share/agent-heavy/install-in-progress.json") + if _, err := appservices.ReadFile(c.p.home, pending, 16384, false); err == nil { + return errors.New("Installation is incomplete; finish the installer before enabling phone access") + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("Cannot verify completed installation before enabling phone access: %w", err) + } + } + state, err := c.managedServiceState("status") + if err != nil { + return err + } + if state == "requiresApproval" { + return errors.New("Allow Agent Queue's background services in Login Items settings before enabling phone access") + } + if state == "notFound" { + return errors.New("The installed phone service definition is unavailable") + } + } if err := c.p.prepare(); err != nil { return err } @@ -1579,9 +1634,11 @@ func (c *controller) supervisor() (result error) { // Main is the standalone remote controller entrypoint. func Main(arguments []string) int { - return runMain(arguments, os.Stdin, prepareConfiguration, func() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), 30*time.Second) - }) + return runMain(arguments, os.Stdin, prepareConfiguration, defaultLifecycleContext) +} + +func defaultLifecycleContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), appservices.ControllerLifecycleTimeout) } func runMain(arguments []string, inputReader io.Reader, prepare func(map[string]json.RawMessage) (*preparedConfiguration, error), lifecycleContext func() (context.Context, context.CancelFunc)) int { @@ -1637,6 +1694,7 @@ func runMain(arguments []string, inputReader io.Reader, prepare func(map[string] defer cancel() } c := newController(p, ctx) + c.installMigration = action == "migrate" switch action { case "configure", "enable", "disable", "migrate": if err = p.prepare(); err != nil { diff --git a/internal/remote/managed_service.go b/internal/remote/managed_service.go new file mode 100644 index 0000000..ae9c0c9 --- /dev/null +++ b/internal/remote/managed_service.go @@ -0,0 +1,86 @@ +package remote + +import ( + "errors" + "fmt" + + "github.com/Dunky13/agent-job-queue/internal/appservices" +) + +// Lifecycle calls execute only the installed owning app. Missing or malformed +// ownership metadata never falls back to writing an unmanaged LaunchAgent. +func (c *controller) managedServiceState(action string) (string, error) { + if err := appservices.ValidateApp(c.p.home, c.p.managedApp); err != nil { + return "", fmt.Errorf("Cannot verify the installed phone service owner: %w", err) + } + arguments, err := appservices.Arguments(c.p.managedApp, action, "remote") + if err != nil { + return "", err + } + var output []byte + if c.managedCommand != nil { + output, err = c.managedCommand(arguments) + } else { + // A pending-approval mutation returns JSON and exit 2. Decode its state + // even on that exit; only the requested observed end state can succeed. + timeout := appservices.NativeCommandTimeout + if action == "status" { + timeout = appservices.NativeStatusTimeout + } + output, err = c.command(arguments, nil, timeout, false) + } + if err != nil { + return "", err + } + status, err := appservices.DecodeStatus(output, "remote") + if err != nil { + return "", fmt.Errorf("Invalid native phone service response: %w", err) + } + return status.Status, nil +} + +func (c *controller) managedServiceAction(action string) error { + if action != "start" && action != "stop" { + return errors.New("Unsupported managed phone service action") + } + state, err := c.managedServiceState("status") + if err != nil { + return err + } + if action == "stop" { + if state == "notRegistered" { + return nil + } + if state == "notFound" { + return errors.New("The installed phone service definition is unavailable") + } + state, err = c.managedServiceState("unregister") + if err != nil { + return err + } + if state != "notRegistered" { + return errors.New("Phone service removal did not finish; review Login Items in System Settings") + } + return nil + } + if state == "requiresApproval" { + return errors.New("Allow Agent Queue's background services in Login Items settings before enabling phone access") + } + if state == "enabled" { + return nil + } + if state != "notRegistered" { + return errors.New("The installed phone service definition is unavailable") + } + state, err = c.managedServiceState("register") + if err != nil { + return err + } + if state == "requiresApproval" { + return errors.New("Allow Agent Queue's background services in Login Items settings before enabling phone access") + } + if state != "enabled" { + return errors.New("Phone service registration did not finish") + } + return nil +} diff --git a/internal/remote/managed_service_test.go b/internal/remote/managed_service_test.go new file mode 100644 index 0000000..d38c4ff --- /dev/null +++ b/internal/remote/managed_service_test.go @@ -0,0 +1,244 @@ +package remote + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/Dunky13/agent-job-queue/internal/appservices" + "howett.net/plist" +) + +func TestManagedPhoneDeadlinesReachActualCommand(t *testing.T) { + c := fixtureManagedController(t) + lifecycle, cancel := defaultLifecycleContext() + defer cancel() + deadline, ok := lifecycle.Deadline() + if !ok || time.Until(deadline) < 2*appservices.NativeCommandTimeout { + t.Fatal("lifecycle cannot contain native stop and start") + } + // Account for earlier status and ownership work without sleeping. The real + // command seam must still give unregister its complete native command bound. + c.ctx, cancel = context.WithDeadline(lifecycle, deadline.Add(-20*time.Second)) + defer cancel() + launcher := filepath.Join(c.p.managedApp, "Contents/MacOS/AgentQueue") + if err := os.WriteFile(launcher, []byte("#!/bin/sh\nprintf '%s\\n' '{\"schemaVersion\":1,\"service\":\"remote\",\"status\":\"notRegistered\"}'\n"), 0700); err != nil { + t.Fatal(err) + } + for _, action := range []string{"status", "unregister"} { + var commandDeadline time.Time + c.newCommand = func(ctx context.Context, name string, args ...string) *exec.Cmd { + commandDeadline, _ = ctx.Deadline() + if name != launcher || !slices.Equal(args, []string{"--service-management", action, "remote"}) { + t.Fatal("deadline probe escaped owning native command", name, args) + } + return exec.CommandContext(ctx, name, args...) + } + started := time.Now() + if state, err := c.managedServiceState(action); err != nil || state != "notRegistered" { + t.Fatal("actual private native command failed", state, err) + } + want := appservices.NativeCommandTimeout + if action == "status" { + want = appservices.NativeStatusTimeout + } + if got := commandDeadline.Sub(started); got < want-time.Second || got > want+time.Second { + t.Fatalf("parent lifecycle truncated %s command: %s, want %s", action, got, want) + } + } + short, stop := context.WithTimeout(context.Background(), time.Millisecond) + defer stop() + c.ctx = short + var actual time.Time + c.newCommand = func(ctx context.Context, name string, args ...string) *exec.Cmd { + actual, _ = ctx.Deadline() + return exec.CommandContext(ctx, name, args...) + } + _, _ = c.managedServiceState("unregister") + shortDeadline, _ := short.Deadline() + if actual.IsZero() || actual.After(shortDeadline) { + t.Fatal("native command extended caller cancellation", actual, shortDeadline) + } +} + +func fixtureManagedController(t *testing.T) *controller { + t.Helper() + c := fixtureController(t) + canonicalHome, err := filepath.EvalSymlinks(c.p.home) + if err != nil { + t.Fatal(err) + } + c.p.home = canonicalHome + c.p.managedApp = filepath.Join(c.p.home, "Applications/Agent Queue.app") + write := func(name string, raw []byte, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(name), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(name, raw, mode); err != nil { + t.Fatal(err) + } + } + info, err := plist.Marshal(map[string]any{"CFBundleIdentifier": "io.went.agent-queue", "AgentQueueServiceManagement": true}, plist.XMLFormat) + if err != nil { + t.Fatal(err) + } + write(filepath.Join(c.p.managedApp, "Contents/Info.plist"), info, 0600) + write(filepath.Join(c.p.managedApp, "Contents/Resources/payload/source/build-info.json"), + []byte(`{"schemaVersion":1,"repository":"Dunky13/agent-job-queue","version":"1.2.3","commit":null,"build":null}`), 0600) + for _, name := range []string{"AgentQueue", "agent-heavy", "agent-remote"} { + write(filepath.Join(c.p.managedApp, "Contents/MacOS", name), []byte("#!/bin/sh\nexit 99\n"), 0700) + } + for _, role := range []string{"heavy", "resources", "remote"} { + helper := "agent-heavy" + action := "daemon" + if role == "remote" { + helper = "agent-remote" + action = "serve" + } else if role == "resources" { + action = "scheduler" + } + definition, err := plist.Marshal(map[string]any{"Label": "io.went.agent-" + role, "BundleProgram": "Contents/MacOS/" + helper, + "ProgramArguments": []string{helper, action}}, plist.XMLFormat) + if err != nil { + t.Fatal(err) + } + write(filepath.Join(c.p.managedApp, "Contents/Library/LaunchAgents/io.went.agent-"+role+".plist"), definition, 0600) + } + return c +} + +func TestManagedPhoneServiceUsesOwningBundleAndNoUserPlist(t *testing.T) { + c := fixtureManagedController(t) + state := "notRegistered" + var actions []string + c.managedCommand = func(args []string) ([]byte, error) { + if len(args) != 4 || args[0] != filepath.Join(c.p.managedApp, "Contents/MacOS/AgentQueue") || args[1] != "--service-management" || args[3] != "remote" { + t.Fatalf("unexpected native call: %v", args) + } + actions = append(actions, args[2]) + if args[2] == "register" { + state = "enabled" + } else if args[2] == "unregister" { + state = "notRegistered" + } + return json.Marshal(appservices.Status{SchemaVersion: 1, Service: "remote", Status: state}) + } + if err := c.launchAgent(); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(c.p.plist); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed phone service wrote a user LaunchAgent: %v", err) + } + if err := c.serviceAction("start"); err != nil { + t.Fatal(err) + } + if err := c.serviceAction("stop"); err != nil { + t.Fatal(err) + } + if !slices.Equal(actions, []string{"status", "register", "status", "unregister"}) { + t.Fatalf("unexpected lifecycle actions: %v", actions) + } +} + +func TestManagedPhoneEnablePreservesRevokedApprovalAndConfiguration(t *testing.T) { + c := fixtureManagedController(t) + settings := testSettings() + settings.Enabled = false + if err := writePrivate(c.p.config, settings); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(c.p.config) + if err != nil { + t.Fatal(err) + } + var calls int + c.managedCommand = func(args []string) ([]byte, error) { + calls++ + if args[2] != "status" { + t.Fatal("revoked approval caused a registration mutation") + } + return []byte(`{"schemaVersion":1,"service":"remote","status":"requiresApproval"}`), nil + } + if err := c.enable(); err == nil || !strings.Contains(err.Error(), "Login Items") { + t.Fatalf("revoked approval did not block enable: %v", err) + } + after, err := os.ReadFile(c.p.config) + if err != nil || string(before) != string(after) || calls != 1 { + t.Fatalf("revoked approval mutated configuration or retried: %v %d", err, calls) + } +} + +func TestManagedPhoneServiceFailsClosedOnNativeErrors(t *testing.T) { + for _, response := range []string{`{}`, `{"schemaVersion":1,"service":"queue","status":"enabled"}`, `{"schemaVersion":1,"service":"remote","status":"enabled","status":"notRegistered"}`} { + t.Run(response, func(t *testing.T) { + c := fixtureManagedController(t) + c.managedCommand = func([]string) ([]byte, error) { return []byte(response), nil } + if err := c.serviceAction("start"); err == nil { + t.Fatal("invalid native status accepted") + } + if _, err := os.Lstat(c.p.plist); !errors.Is(err, os.ErrNotExist) { + t.Fatal("invalid native status fell back to a user plist") + } + }) + } + c := fixtureManagedController(t) + witness := errors.New("native service unavailable") + c.managedCommand = func([]string) ([]byte, error) { return nil, witness } + if err := c.serviceAction("stop"); !errors.Is(err, witness) { + t.Fatalf("native service error was lost: %v", err) + } +} + +func TestManagedPhonePendingRegistrationIsNotSuccess(t *testing.T) { + c := fixtureManagedController(t) + c.managedCommand = func(args []string) ([]byte, error) { + state := "notRegistered" + if args[2] == "register" { + state = "requiresApproval" + } + return json.Marshal(appservices.Status{SchemaVersion: 1, Service: "remote", Status: state}) + } + if err := c.serviceAction("start"); err == nil || !strings.Contains(err.Error(), "Login Items") { + t.Fatalf("pending registration reported success: %v", err) + } +} + +func TestManagedPhoneIncompleteInstallCannotEnableLegacyService(t *testing.T) { + c := fixtureController(t) + c.p.managedCandidate = filepath.Join(c.p.home, "Applications/Agent Queue.app") + if err := c.enable(); err == nil { + t.Fatal("uninstalled managed helper enabled a legacy service") + } + if _, err := os.Lstat(c.p.config); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("incomplete installation changed phone configuration: %v", err) + } +} + +func TestManagedPhonePendingInstallBlocksOrdinaryEnableBeforeMutation(t *testing.T) { + c := fixtureManagedController(t) + pending := filepath.Join(c.p.home, ".local/share/agent-heavy/install-in-progress.json") + if err := os.MkdirAll(filepath.Dir(pending), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pending, []byte(`{"build":42}`), 0600); err != nil { + t.Fatal(err) + } + c.managedCommand = func([]string) ([]byte, error) { + t.Fatal("pending installation reached the native service bridge") + return nil, nil + } + if err := c.enable(); err == nil || !strings.Contains(err.Error(), "Installation is incomplete") { + t.Fatalf("pending install did not block ordinary enable: %v", err) + } + if _, err := os.Lstat(c.p.config); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("pending installation changed phone access intent: %v", err) + } +} diff --git a/internal/tooling/dmg_test.go b/internal/tooling/dmg_test.go index 954648b..9c708f6 100644 --- a/internal/tooling/dmg_test.go +++ b/internal/tooling/dmg_test.go @@ -89,6 +89,10 @@ func TestDiskImageVerificationSelectsOnlyCompleteDistributionLayouts(t *testing. }{ {"drag", "verify-app", "--app", "Agent Queue.app"}, {"legacy", "verify-installer", "--installer", "Agent Job Queue Installer.app"}, + {"managed installer", "verify-installer", "--installer", "Agent Job Queue Installer.app"}, + {"managed drag", "verify-app", "--app", "Agent Queue.app"}, + {"managed missing helper", "", "", ""}, + {"managed non executable helper", "", "", ""}, {"missing apps", "", "", ""}, {"mixed", "", "", ""}, {"missing link", "", "", ""}, @@ -119,7 +123,7 @@ attach) mountpoint=$1 printf '%s' "$mountpoint" > "$DMG_TEST_RECORD" [ "$DMG_TEST_LAYOUT" != 'missing apps' ] || exit 0 - if [ "$DMG_TEST_LAYOUT" = legacy ] || [ "$DMG_TEST_LAYOUT" = 'legacy link' ] || [ "$DMG_TEST_LAYOUT" = mixed ]; then + if [ "$DMG_TEST_LAYOUT" = legacy ] || [ "$DMG_TEST_LAYOUT" = 'legacy link' ] || [ "$DMG_TEST_LAYOUT" = mixed ] || [ "$DMG_TEST_LAYOUT" = "managed installer" ]; then for file in 'Contents/MacOS/Installer' \ 'Contents/Resources/payload/Agent Queue.app/Contents/MacOS/AgentQueue' \ 'Contents/Resources/payload/source/bin/agent-heavy' \ @@ -129,6 +133,13 @@ attach) printf 'fixture' > "$target" chmod 700 "$target" done + if [ "$DMG_TEST_LAYOUT" = 'managed installer' ]; then + app="$mountpoint/Agent Job Queue Installer.app/Contents/Resources/payload/Agent Queue.app" + for name in agent-heavy agent-remote; do + cp "$mountpoint/Agent Job Queue Installer.app/Contents/Resources/payload/source/bin/$name" "$app/Contents/MacOS/$name" + done + exit 0 + fi if [ "$DMG_TEST_LAYOUT" = legacy ]; then exit 0; fi if [ "$DMG_TEST_LAYOUT" = 'legacy link' ]; then ln -s /Applications "$mountpoint/Applications"; exit 0; fi fi @@ -142,7 +153,14 @@ attach) chmod 700 "$target" done case "$DMG_TEST_LAYOUT" in - 'missing link') ;; + managed*) + mv "$app/Contents/Resources/payload/source/bin/agent-heavy" "$app/Contents/MacOS/agent-heavy" + mv "$app/Contents/Resources/payload/source/bin/agent-remote" "$app/Contents/MacOS/agent-remote" + if [ "$DMG_TEST_LAYOUT" = 'managed missing helper' ]; then rm "$app/Contents/MacOS/agent-remote"; fi + if [ "$DMG_TEST_LAYOUT" = 'managed non executable helper' ]; then chmod 600 "$app/Contents/MacOS/agent-heavy"; fi ;; + esac + case "$DMG_TEST_LAYOUT" in + 'missing link' ) ;; 'wrong link') ln -s /tmp "$mountpoint/Applications";; 'relative link') ln -s Applications "$mountpoint/Applications";; 'newline link') target=$(printf '/Applications\n.'); ln -s "${target%.}" "$mountpoint/Applications";; @@ -163,6 +181,7 @@ detach) exit 0;; *) exit 99;; esac `) + testTool(t, root, "plutil", `case "$DMG_TEST_LAYOUT" in managed*) exit 0;; *) exit 1;; esac`) testTool(t, root, "go", `printf 'go' >> "$DMG_TEST_EVENTS" for argument in "$@"; do printf '|%s' "$argument" >> "$DMG_TEST_EVENTS"; done printf '\n' >> "$DMG_TEST_EVENTS" diff --git a/internal/tooling/homebrew_lifecycle_test.go b/internal/tooling/homebrew_lifecycle_test.go index 6e3f726..0e9a6e3 100644 --- a/internal/tooling/homebrew_lifecycle_test.go +++ b/internal/tooling/homebrew_lifecycle_test.go @@ -14,9 +14,8 @@ import ( "github.com/Dunky13/agent-job-queue/internal/backend" ) -// Exercise Homebrew's actual App movement and install-artifact rollback. The -// setup executable refuses execution; native first-launch acceptance separately -// runs the real Go installer after Homebrew has finished moving the signed app. +// Exercise actual Homebrew delivery movement and rollback, with executable +// sentinels that fail if delivery attempts setup or any runtime operation. func TestHomebrewNativeAppLifecycle(t *testing.T) { if runtime.GOOS != "darwin" { t.Skip("Homebrew app lifecycle requires macOS") @@ -25,7 +24,7 @@ func TestHomebrewNativeAppLifecycle(t *testing.T) { if err != nil { t.Skip("Homebrew is unavailable") } - for _, prefix := range []string{"opt/homebrew", "usr/local", "custom/prefix"} { + for _, prefix := range []string{"opt/homebrew", "usr/local", "custom/prefix", "historical"} { t.Run(prefix, func(t *testing.T) { root, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { @@ -63,7 +62,7 @@ func TestHomebrewNativeAppLifecycle(t *testing.T) { } } output, err := command.CombinedOutput() - if err != nil || !strings.Contains(string(output), "native app install, rollback, and uninstall verified") { + if err != nil || !strings.Contains(string(output), "installer delivery, rollback, runtime preservation and uninstall verified") { t.Fatalf("Homebrew lifecycle failed: %v\n%s", err, output) } }) @@ -73,7 +72,8 @@ func TestHomebrewNativeAppLifecycle(t *testing.T) { const homebrewLifecycleRuby = `require "cask/cask_loader" require "cask/caskroom" require "cask/installer" - +require "find" +require File.expand_path("../../packaging/homebrew/test-cask.rb") fixture, prefix = Pathname(ARGV.fetch(0)), ARGV.fetch(1) home = fixture/"User With Spaces" raise "Unexpected test home" unless Pathname(Dir.home) == home @@ -83,94 +83,120 @@ FileUtils.chmod(0o775, [caskroom.parent, caskroom]) shared_modes = [caskroom.parent, caskroom].map { |path| path.stat.mode & 0o777 } Cask::Caskroom.instance_variable_set(:@path, caskroom) -# Every artifact, including real binary symlinks and uninstall scripts, remains -# inside this fixture. quit:false below prevents targeting the operator's app. def load_cask(fixture, build) cask = Cask::CaskLoader.load(fixture/build.to_s/"agent-job-queue.rb") cask.config = Cask::Config.new(explicit: { appdir: (fixture/"Ignored custom appdir").to_s }) cask.config.instance_variable_set(:@binarydir, fixture/"bin") - apps = cask.artifacts.grep(Cask::Artifact::App) - raise "Missing native App artifact" unless apps.length == 1 - raise "Custom appdir changed the per-user target" unless apps.first.target == Pathname(Dir.home)/"Applications/Agent Queue.app" - raise "Fixture binary escaped its private prefix" unless cask.artifacts.grep(Cask::Artifact::Binary).all? { |artifact| artifact.target.dirname == fixture/"bin" } - forbidden = [Cask::Artifact::Installer, Cask::Artifact::PreflightBlock, Cask::Artifact::PostflightBlock] - forbidden << Cask::Artifact::PreflightSteps if defined?(Cask::Artifact::PreflightSteps) - forbidden << Cask::Artifact::PostflightSteps if defined?(Cask::Artifact::PostflightSteps) - raise "Cask has executable install hooks" if cask.artifacts.any? { |artifact| forbidden.any? { |klass| artifact.is_a?(klass) } } + verify_delivery_cask_contract(cask) cask end -def stage_app(cask, build) - app = cask.artifacts.grep(Cask::Artifact::App).fetch(0) - bin = app.source/"Contents/Resources/payload/source/bin" - links = app.source.parent/"source/links" - FileUtils.mkdir_p([bin, links]) - (app.source/"build").write(build.to_s) - helper = bin/"agent-heavy" - helper.write("#!/bin/sh\nprintf 'unexpected setup' > \"$HOME/setup-executed\"\nexit 99\n") - helper.chmod(0o755) +def stage_installer(cask, build, historical) + source = cask.artifacts.grep(Cask::Artifact::App).first.source + payload = source/"Contents/Resources/payload" + helper_dir = historical ? payload/"Agent Queue.app/Contents/Resources/payload/source/bin" : payload/"Agent Queue.app/Contents/MacOS" + forbidden = "#!/bin/sh\nprintf 'forbidden execution: %s\\n' \"$*\" >> \"$HOME/forbidden-execution\"\nexit 95\n" + [source/"Contents/MacOS/Installer", payload/"source/bin/agent-heavy", payload/"source/bin/agent-remote", + payload/"Agent Queue.app/Contents/MacOS/AgentQueue", helper_dir/"agent-heavy", helper_dir/"agent-remote"].each do |path| + path.dirname.mkpath + path.write(forbidden) + path.chmod(0o755) + end + (source/"build").write(build.to_s) %w[agent-heavy agent-remote].each do |name| - wrapper = links/name - wrapper.write("#!/bin/sh\nexit 99\n") - wrapper.chmod(0o755) + path = payload/"source/links"/name + path.dirname.mkpath + FileUtils.cp(File.expand_path("../../packaging/links/#{name}"), path) end - app.source + source end -def verify_cli_links(cask) +def verify_links(cask, fixture) cask.artifacts.grep(Cask::Artifact::Binary).each do |binary| - unless binary.target.symlink? && binary.target.realpath == binary.source.realpath - raise "CLI link does not resolve to its expected cask source: #{binary.target}" - end + raise "CLI target escaped private prefix" unless binary.target.dirname == fixture/"bin" + raise "CLI link lost delivered installer source" unless binary.target.symlink? && binary.target.realpath == binary.source.realpath end end -state = home/".local/state/agent-heavy/keep" -credentials = home/".config/agent-heavy/keep" -launch_agent = home/"Library/LaunchAgents/io.went.agent-queue.plist" -[state, credentials, launch_agent].each do |path| +runtime_app = home/"Applications/Agent Queue.app" +private_files = [runtime_app/"Contents/MacOS/AgentQueue", runtime_app/"Contents/MacOS/agent-heavy", + home/".local/share/agent-heavy/installation.json", home/".local/state/agent-heavy/queue.sqlite3", + home/".config/agent-heavy/credentials", home/".local/bin/agent-heavy", + home/"Library/LaunchAgents/io.went.agent-queue.plist", home/"Library/LaunchAgents/io.went.agent-heavy.plist", + home/"Library/LaunchAgents/io.went.agent-remote.plist"] +private_files.each do |path| path.dirname.mkpath - path.write("private sentinel") + path.write("preserved runtime sentinel: #{path.basename}") +end +witness = private_files.to_h { |path| [path, [path.binread, path.stat.ino, path.stat.mode]] } +verify_runtime = lambda do + witness.each { |path, expected| raise "Delivery moved/deleted/changed runtime #{path}" unless path.exist? && [path.binread, path.stat.ino, path.stat.mode] == expected } + raise "Delivery executed setup, queue or native command" if (home/"forbidden-execution").exist? end +historical = prefix == "historical" old = load_cask(fixture, 1) -old_source = stage_app(old, 1) -old_installer = Cask::Installer.new(old, upgrade: true) +old_source = stage_installer(old, 1, historical) old.metadata_main_container_path.mkpath +old_installer = Cask::Installer.new(old, upgrade: true) old_installer.install_artifacts -app = old.artifacts.grep(Cask::Artifact::App).fetch(0) -raise "App remained in shared staging" unless app.source.symlink? && app.target.directory? -raise "Installed app is group-writable" unless (app.target.stat.mode & 0o022).zero? -verify_cli_links(old) +app = old.artifacts.grep(Cask::Artifact::App).first +raise "Installer was not relocated" unless app.source.symlink? && (app.target/"build").read == "1" +verify_links(old, fixture) +verify_runtime.call +# A later native artifact failure must roll back delivery alone. new = load_cask(fixture, 2) -stage_app(new, 2) -new_installer = Cask::Installer.new(new, upgrade: true) -# Fail a real later artifact, after Homebrew has installed the replacement App. -new.artifacts.grep(Cask::Artifact::Binary).last.define_singleton_method(:install_phase) do |**_options| - raise "injected private binary-link failure" -end +stage_installer(new, 2, historical) +new.artifacts.grep(Cask::Artifact::Binary).last.define_singleton_method(:install_phase) { |**_options| raise "injected private binary-link failure" } old_installer.uninstall_artifacts(successor: new, quit: false) -raise "Old app was not backed up" unless old_source.directory? && !old_source.symlink? && (!app.target.exist? || app.target.children.empty?) -failed = false +raise "Old installer was not backed up" unless old_source.directory? && !old_source.symlink? +verify_runtime.call begin - new_installer.install_artifacts(predecessor: old) + Cask::Installer.new(new, upgrade: true).install_artifacts(predecessor: old) + raise "Delivery failure was swallowed" rescue RuntimeError => error - failed = true - raise "Unexpected install failure" unless error.message == "injected private binary-link failure" + raise unless error.message == "injected private binary-link failure" + raise "Rollback left replacement installer" if app.target.exist? end -raise "Failed installation was swallowed" unless failed -raise "Failed installation left its replacement app" if app.target.exist? old_installer.install_artifacts(predecessor: new) -raise "Old app was not restored" unless (app.target/"build").read == "1" -verify_cli_links(old) +raise "Rollback lost old installer" unless (app.target/"build").read == "1" +verify_links(old, fixture) +verify_runtime.call -launch_agent.write("private sentinel") -old_installer.uninstall_artifacts(quit: false) -raise "Uninstall left the menu app" if app.target.exist? -raise "Uninstall left the app launch agent" if launch_agent.exist? -raise "Uninstall left the CLI links" if %w[agent-heavy agent-remote].any? { |name| (fixture/"bin"/name).symlink? } -raise "Homebrew executed native setup" if (home/"setup-executed").exist? -[state, credentials].each { |path| raise "Lifecycle changed private queue state" unless path.read == "private sentinel" } -raise "Lifecycle changed shared Homebrew permissions" unless [caskroom.parent, caskroom].map { |path| path.stat.mode & 0o777 } == shared_modes -puts "native app install, rollback, and uninstall verified" +new = load_cask(fixture, 2) +stage_installer(new, 2, historical) +new_installer = Cask::Installer.new(new, upgrade: true) +old_installer.uninstall_artifacts(successor: new, quit: false) +new_installer.install_artifacts(predecessor: old) +raise "Delivery upgrade did not replace installer" unless (app.target/"build").read == "2" +verify_links(new, fixture) +verify_runtime.call +new_installer.uninstall_artifacts(quit: false) +raise "Uninstall left installer delivery" if app.target.exist? +raise "Uninstall left CLI delivery links" if %w[agent-heavy agent-remote].any? { |name| (fixture/"bin"/name).symlink? } +verify_runtime.call +raise "Delivery changed shared permissions" unless [caskroom.parent, caskroom].map { |path| path.stat.mode & 0o777 } == shared_modes +puts "installer delivery, rollback, runtime preservation and uninstall verified" ` + +func TestPackagedHomebrewLinksUseGeneratedCanonicalWrappers(t *testing.T) { + for _, name := range []string{"agent-heavy", "agent-remote"} { + t.Run(name, func(t *testing.T) { + home := t.TempDir() + generated := filepath.Join(home, ".local/bin", name) + verificationWrite(t, generated, "#!/bin/sh\nprintf '%s\\n' \"$@\" \"$PACKAGED_TEST_VALUE\"\nexit 7\n", 0700) + wrapper, err := filepath.Abs(filepath.Join("../../packaging/links", name)) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, wrapper, "argument with spaces", "--flag") + command.Env = []string{"HOME=" + home, "PATH=/usr/bin:/bin", "PACKAGED_TEST_VALUE=literal;$HOME"} + output, err := command.CombinedOutput() + if command.ProcessState == nil || command.ProcessState.ExitCode() != 7 || string(output) != "argument with spaces\n--flag\nliteral;$HOME\n" { + t.Fatalf("canonical wrapper forwarding failed: %v: %s", err, output) + } + }) + } +} diff --git a/internal/tooling/installer.go b/internal/tooling/installer.go index b19f355..a7fcf58 100644 --- a/internal/tooling/installer.go +++ b/internal/tooling/installer.go @@ -7,16 +7,18 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "sort" "strconv" "strings" + "github.com/Dunky13/agent-job-queue/internal/appservices" "github.com/Dunky13/agent-job-queue/internal/backend" "howett.net/plist" ) -// VerifyInstaller checks the distributed installer and both copies of its Go -// payload. Only retained legacy installers may omit the app's embedded payload. +// VerifyInstaller checks the outer control payload and the app-owned helpers. +// Only retained legacy installers may omit the app's embedded support payload. func VerifyInstaller(installer string) error { if installer == "" { return errors.New("Installer path is required") @@ -59,16 +61,20 @@ func verifyApp(app string, expected []byte, requirePayload bool) error { if err := verifyPayloadTree(app, true); err != nil { return err } + managed, err := managedApp(app) + if err != nil { + return err + } payload := filepath.Join(app, "Contents/Resources/payload") - _, err := os.Lstat(payload) + _, err = os.Lstat(payload) switch { - case errors.Is(err, os.ErrNotExist) && !requirePayload: + case errors.Is(err, os.ErrNotExist) && !requirePayload && !managed: // Old retained installers predate the self-contained app. A present but // partial payload must never take this compatibility path. case err != nil: return fmt.Errorf("App embedded payload is missing: %w", err) default: - raw, err := verifySource(filepath.Join(payload, "source")) + raw, err := verifySourceLayout(filepath.Join(payload, "source"), app, managed) if err != nil { return err } @@ -84,6 +90,10 @@ func verifyApp(app string, expected []byte, requirePayload bool) error { } func verifySource(source string) ([]byte, error) { + return verifySourceLayout(source, "", false) +} + +func verifySourceLayout(source, app string, managed bool) ([]byte, error) { if err := verifyPayloadTree(source, true); err != nil { return nil, err } @@ -97,6 +107,9 @@ func verifySource(source string) ([]byte, error) { } for _, name := range []string{"agent-heavy", "agent-remote"} { binary := filepath.Join(source, "bin", name) + if managed { + binary = filepath.Join(app, "Contents/MacOS", name) + } if err := universalExecutable(binary); err != nil { return nil, err } @@ -111,7 +124,9 @@ func verifySource(source string) ([]byte, error) { if err != nil { return nil, err } - if !strings.Contains(string(wrapper), "/.local/share/agent-heavy/bin/"+name) { + legacy := strings.Contains(string(wrapper), "/.local/share/agent-heavy/bin/"+name) + current := strings.Contains(string(wrapper), "/.local/bin/"+name) + if !current && (managed || !legacy) { return nil, fmt.Errorf("Cask wrapper does not use the stable backend: %s", name) } } @@ -127,9 +142,88 @@ func verifySource(source string) ([]byte, error) { if strings.TrimSpace(string(version)) != identity.Version { return nil, errors.New("Payload VERSION differs from its build identity") } + if managed { + if _, err := os.Lstat(filepath.Join(source, "bin")); !errors.Is(err, os.ErrNotExist) { + return nil, errors.New("Managed app must not embed source/bin") + } + if err := verifyManagedAgents(app); err != nil { + return nil, err + } + // Reuse the installer's no-follow ownership and permission boundary. + // macOS temporary-directory ancestors may use their /var aliases. + canonical, err := filepath.EvalSymlinks(app) + if err != nil { + return nil, err + } + canonical, err = filepath.Abs(canonical) + if err != nil { + return nil, err + } + if err := appservices.ValidateBundle(canonical); err != nil { + return nil, err + } + } return raw, nil } +// Presence of a malformed marker must not select retained-artifact compatibility. +func managedApp(app string) (bool, error) { + raw, err := os.ReadFile(filepath.Join(app, "Contents/Info.plist")) + if err != nil { + return false, err + } + var info map[string]any + if _, err := plist.Unmarshal(raw, &info); err != nil { + return false, err + } + marker, present := info["AgentQueueServiceManagement"] + if present && marker != true { + return false, errors.New("Invalid service management packaging marker") + } + return present, nil +} + +func verifyManagedAgents(app string) error { + directory := filepath.Join(app, "Contents/Library/LaunchAgents") + entries, err := os.ReadDir(directory) + if err != nil { + return err + } + if len(entries) != 3 { + return errors.New("Managed app must contain exactly three bundled LaunchAgents") + } + for service, command := range map[string]string{"heavy": "daemon", "resources": "scheduler", "remote": "serve"} { + helper := "agent-heavy" + if service == "remote" { + helper = "agent-remote" + } + label := "io.went.agent-" + service + filename := filepath.Join(directory, label+".plist") + if err := requiredPayloadFile(filename, false); err != nil { + return err + } + raw, err := os.ReadFile(filename) + if err != nil { + return err + } + var actual map[string]any + if _, err := plist.Unmarshal(raw, &actual); err != nil { + return err + } + expected := map[string]any{"Label": label, "BundleProgram": "Contents/MacOS/" + helper, "ProgramArguments": []any{helper, command}, "ThrottleInterval": uint64(10), "Umask": uint64(63), "ProcessType": "Background"} + if service == "resources" { + expected["KeepAlive"] = true + } else { + expected["RunAtLoad"] = true + expected["KeepAlive"] = map[string]any{"SuccessfulExit": false} + } + if !reflect.DeepEqual(actual, expected) { + return fmt.Errorf("Invalid bundled LaunchAgent definition: %s", label) + } + } + return nil +} + func verifyNativeBundle(bundle, executable, identifier string, raw []byte) error { identity, err := backend.ValidateBuildInfo(raw) if err != nil { diff --git a/internal/tooling/installer_test.go b/internal/tooling/installer_test.go index 4c882b5..0476f29 100644 --- a/internal/tooling/installer_test.go +++ b/internal/tooling/installer_test.go @@ -272,3 +272,137 @@ func TestVerifyInstallerChecksEmbeddedPayloadAndRetainedLegacyApp(t *testing.T) }) } } + +// Managed artifacts move the app's helper copies rather than adding another +// executable tree. Legacy fixtures above deliberately keep their old layout. +func managedVerificationFixture(t *testing.T) verificationFixture { + t.Helper() + f := newVerificationFixture(t) + info := filepath.Join(f.app, "Contents/Info.plist") + raw, err := os.ReadFile(info) + if err != nil { + t.Fatal(err) + } + verificationWrite(t, info, strings.Replace(string(raw), "", "AgentQueueServiceManagement", 1), 0600) + for _, name := range []string{"agent-heavy", "agent-remote"} { + if err := os.Rename(filepath.Join(f.source, "bin", name), filepath.Join(f.app, "Contents/MacOS", name)); err != nil { + t.Fatal(err) + } + verificationWrite(t, filepath.Join(f.source, "links", name), "#!/bin/sh\nexec \"${HOME:?}/.local/bin/"+name+"\" \"$@\"\n", 0700) + } + if err := os.Remove(filepath.Join(f.source, "bin")); err != nil { + t.Fatal(err) + } + for _, name := range []string{"heavy", "resources", "remote"} { + filename := "io.went.agent-" + name + ".plist" + raw, err := os.ReadFile(filepath.Join("../../native/LaunchAgents", filename)) + if err != nil { + t.Fatal(err) + } + verificationWrite(t, filepath.Join(f.app, "Contents/Library/LaunchAgents", filename), string(raw), 0600) + } + return f +} + +func TestVerifyManagedAppAndInstaller(t *testing.T) { + for _, installer := range []bool{false, true} { + t.Run(fmt.Sprint(installer), func(t *testing.T) { + f := managedVerificationFixture(t) + if !installer { + if err := VerifyApp(f.app); err != nil { + t.Fatal(err) + } + } else { + bundle := filepath.Join(f.root, "Installer.app") + verificationBundle(t, bundle, "Installer", "io.went.agent-queue.installer") + payload := filepath.Join(bundle, "Contents/Resources/payload") + verificationSource(t, filepath.Join(payload, "source")) + if err := os.Rename(f.app, filepath.Join(payload, "Agent Queue.app")); err != nil { + t.Fatal(err) + } + if err := VerifyInstaller(bundle); err != nil { + t.Fatal(err) + } + } + }) + } +} + +func TestVerifyManagedAppRejectsTampering(t *testing.T) { + for _, mutation := range []string{"marker type", "marker false", "missing payload", "hidden bin", "helper missing", "helper signature", "helper architecture", "helper permissions", "writable helper", "hardlinked helper", "missing plist", "plist symlink", "argument", "run policy", "home environment", "log path", "extra login plist", "legacy wrapper"} { + t.Run(mutation, func(t *testing.T) { + f := managedVerificationFixture(t) + agent := filepath.Join(f.app, "Contents/Library/LaunchAgents/io.went.agent-heavy.plist") + helper := filepath.Join(f.app, "Contents/MacOS/agent-heavy") + switch mutation { + case "marker type", "marker false": + info := filepath.Join(f.app, "Contents/Info.plist") + raw, _ := os.ReadFile(info) + value := "true" + if mutation == "marker false" { + value = "" + } + verificationWrite(t, info, strings.Replace(string(raw), "AgentQueueServiceManagement", "AgentQueueServiceManagement"+value, 1), 0600) + case "missing payload": + if err := os.RemoveAll(filepath.Dir(f.source)); err != nil { + t.Fatal(err) + } + case "hidden bin": + verificationWrite(t, filepath.Join(f.source, "bin/agent-heavy"), "duplicate", 0700) + case "helper missing": + if err := os.Remove(helper); err != nil { + t.Fatal(err) + } + case "helper signature": + t.Setenv("VERIFY_TEST_BAD_SIGNATURE", helper) + case "helper architecture": + t.Setenv("VERIFY_TEST_BAD_ARCH", helper) + case "writable helper": + if err := os.Chmod(helper, 0777); err != nil { + t.Fatal(err) + } + case "hardlinked helper": + if err := os.Link(helper, filepath.Join(f.root, "extra-helper")); err != nil { + t.Fatal(err) + } + case "helper permissions": + if err := os.Chmod(helper, 0600); err != nil { + t.Fatal(err) + } + case "missing plist": + if err := os.Remove(agent); err != nil { + t.Fatal(err) + } + case "plist symlink": + target := filepath.Join(f.root, "external.plist") + if err := os.Rename(agent, target); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, agent); err != nil { + t.Fatal(err) + } + case "extra login plist": + verificationWrite(t, filepath.Join(filepath.Dir(agent), "io.went.agent-queue.plist"), "login", 0600) + case "legacy wrapper": + verificationWrite(t, filepath.Join(f.source, "links/agent-heavy"), "#!/bin/sh\nexec \"$HOME/.local/share/agent-heavy/bin/agent-heavy\"", 0700) + default: + raw, _ := os.ReadFile(agent) + value := string(raw) + switch mutation { + case "argument": + value = strings.Replace(value, "daemon", "scheduler", 1) + case "run policy": + value = strings.Replace(value, "SuccessfulExit", "SuccessfulExit", 1) + case "home environment": + value = strings.Replace(value, "", "EnvironmentVariablesHOME/private/var/empty", 1) + case "log path": + value = strings.Replace(value, "", "StandardOutPath/tmp/private.log", 1) + } + verificationWrite(t, agent, value, 0600) + } + if err := VerifyApp(f.app); err == nil { + t.Fatal("managed tampering accepted") + } + }) + } +} diff --git a/internal/tooling/managed_cask_contract_test.go b/internal/tooling/managed_cask_contract_test.go new file mode 100644 index 0000000..ba162de --- /dev/null +++ b/internal/tooling/managed_cask_contract_test.go @@ -0,0 +1,190 @@ +package tooling + +import ( + "context" + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" +) + +func TestManagedCaskTemplateParity(t *testing.T) { + source, err := parser.ParseFile(token.NewFileSet(), "../../packaging/homebrew/refresh.go", nil, 0) + if err != nil { + t.Fatal(err) + } + var standalone string + ast.Inspect(source, func(node ast.Node) bool { + value, ok := node.(*ast.ValueSpec) + if !ok || len(value.Names) != 1 || value.Names[0].Name != "caskTemplate" || len(value.Values) != 1 { + return true + } + literal, ok := value.Values[0].(*ast.BasicLit) + if !ok { + t.Fatal("standalone cask template is not a string literal") + } + standalone, err = strconv.Unquote(literal.Value) + if err != nil { + t.Fatal(err) + } + return false + }) + if standalone == "" || standalone != caskTemplate { + t.Fatal("release and standalone Homebrew lifecycle templates differ") + } +} + +func TestHomebrewDeliveryPreservesQuarantineWithoutExecutingPayload(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Homebrew quarantine delivery requires macOS") + } + brew, err := exec.LookPath("brew") + if err != nil { + t.Skip("Homebrew is unavailable") + } + for _, historical := range []bool{false, true} { + t.Run(strconv.FormatBool(historical), func(t *testing.T) { + root, home, environment, owner := notarizedCaskFixture(t, "AAAAAAAAAA") + protocolPath := filepath.Join(root, "fixture-environment.json") + body, err := os.ReadFile(protocolPath) + if err != nil { + t.Fatal(err) + } + var protocol map[string]string + if err := json.Unmarshal(body, &protocol); err != nil { + t.Fatal(err) + } + delete(protocol, "AGENT_QUEUE_INSTALL_TEST_TEAM") + body, err = json.Marshal(protocol) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(protocolPath, body, 0600); err != nil { + t.Fatal(err) + } + installer := filepath.Join(root, "Agent Job Queue Installer.app") + payload := filepath.Join(installer, "Contents/Resources/payload") + paths := []string{filepath.Join(installer, "Contents/MacOS/Installer"), filepath.Join(payload, "source/bin/agent-heavy"), filepath.Join(payload, "source/bin/agent-remote"), filepath.Join(payload, "Agent Queue.app/Contents/MacOS/AgentQueue")} + helperDir := "Agent Queue.app/Contents/MacOS" + if historical { + helperDir = "Agent Queue.app/Contents/Resources/payload/source/bin" + } + for _, name := range []string{"agent-heavy", "agent-remote"} { + paths = append(paths, filepath.Join(payload, helperDir, name), filepath.Join(payload, "source/links", name)) + } + for _, path := range paths { + verificationWrite(t, path, "#!/bin/sh\ntouch \"$HOME/forbidden-execution\"\nexit 95\n", 0700) + } + runtime := filepath.Join(home, "Applications/Agent Queue.app/Contents/MacOS/AgentQueue") + verificationWrite(t, runtime, "live runtime sentinel", 0700) + before, err := os.Stat(runtime) + if err != nil { + t.Fatal(err) + } + cask := notarizedFixtureCask(t, root, "0.1.0") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + command, err := owner.start(ctx, environment, nil, brew, "ruby", "--", "../../packaging/homebrew/test-cask.rb", cask, root, "--quarantined") + if err != nil { + t.Fatal(err) + } + output, err := command.wait() + if err != nil { + t.Fatalf("quarantined installer delivery failed: %v: %s", err, output) + } + var result struct { + Installed bool `json:"fixture_app_installed"` + Executed bool `json:"fixture_installer_executed"` + Attrs map[string]int `json:"fixture_attributes"` + } + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &result); err != nil || !result.Installed || result.Executed || len(result.Attrs) != 5 || result.Attrs["entries"] == 0 { + t.Fatalf("invalid delivery evidence: %v: %s", err, output) + } + for _, count := range result.Attrs { + if count != result.Attrs["entries"] { + t.Fatal("delivery lost quarantine or unrelated xattrs", result.Attrs) + } + } + after, err := os.Stat(runtime) + if err != nil || !os.SameFile(before, after) { + t.Fatal("delivery replaced the live runtime", err) + } + if _, err := os.Stat(filepath.Join(home, "forbidden-execution")); !os.IsNotExist(err) { + t.Fatal("delivery executed the installer or unsupported historical flags", err) + } + }) + } +} + +func TestManagedCaskLoaderRejectsHookDrift(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("real Homebrew DSL requires macOS") + } + brew, err := exec.LookPath("brew") + if err != nil { + t.Skip("Homebrew is unavailable") + } + root, home, environment, owner := notarizedCaskFixture(t, "AAAAAAAAAA") + valid := notarizedFixtureCask(t, root, "0.1.0") + body, err := os.ReadFile(valid) + if err != nil { + t.Fatal(err) + } + paths := []string{valid} + for _, mutation := range []struct{ name, before, after string }{ + {"runtime-app", `app "Agent Job Queue Installer.app",`, `app "Agent Job Queue Installer.app/Contents/Resources/payload/Agent Queue.app",`}, + {"runtime-target", `Applications/Agent Job Queue Installer.app`, `Applications/Agent Queue.app`}, + {"preflight", " caveats <<~EOS", " preflight_steps do\n run \"/bin/true\"\n end\n caveats <<~EOS"}, + {"postflight", " caveats <<~EOS", " postflight_steps do\n run \"/bin/true\"\n end\n caveats <<~EOS"}, + {"uninstall", " caveats <<~EOS", " uninstall quit: \"io.went.agent-queue\"\n caveats <<~EOS"}, + {"removal", " caveats <<~EOS", " uninstall_preflight_steps do\n run \"/bin/true\"\n end\n caveats <<~EOS"}, + {"binary", "source/links/agent-heavy", "source/bin/agent-heavy"}, + {"binary-target", `source/links/agent-heavy"`, `source/links/agent-heavy", target: "unexpected"`}, + {"extra-app", " caveats <<~EOS", " app \"Other.app\"\n caveats <<~EOS"}, + } { + changed := strings.Replace(string(body), mutation.before, mutation.after, 1) + if changed == string(body) { + t.Fatalf("mutation %s no longer targets the current cask", mutation.name) + } + path := filepath.Join(root, mutation.name, "agent-job-queue.rb") + verificationWrite(t, path, changed, 0600) + paths = append(paths, path) + } + script := filepath.Join(root, "verify-contract.rb") + verificationWrite(t, script, `require File.expand_path("../../packaging/homebrew/test-cask.rb") +ARGV.each_with_index do |path, index| + rejected = false + begin + cask = Cask::CaskLoader.load(Pathname(path)) + verify_delivery_cask_contract(cask) + rescue RuntimeError, MethodDeprecatedError + rejected = true + end + raise "Wrong contract verdict for #{path}" unless rejected == (index > 0) +end +puts "exact delivery contracts verified" +`, 0600) + // Keep the literal HOME in the same path shape used by release acceptance. + if !strings.Contains(home, "User With Spaces") { + t.Fatal("missing spaced HOME") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + command, err := owner.start(ctx, environment, nil, brew, append([]string{"ruby", "--", script}, paths...)...) + if err != nil { + t.Fatal(err) + } + output, err := command.wait() + if err != nil || !strings.Contains(string(output), "exact delivery contracts verified") { + t.Fatalf("cask contract drift check failed: %v: %s", err, output) + } +} diff --git a/internal/tooling/managed_cask_runtime_test.go b/internal/tooling/managed_cask_runtime_test.go new file mode 100644 index 0000000..8637492 --- /dev/null +++ b/internal/tooling/managed_cask_runtime_test.go @@ -0,0 +1,567 @@ +package tooling + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/Dunky13/agent-job-queue/internal/backend" +) + +type managedCaskReply struct { + Output string `json:"output"` + Code int `json:"code"` +} + +// This client is invoked only by private fixture tools. The socket and tool are +// literal wrapper arguments; Homebrew cannot lose them while filtering env. +func TestManagedCaskFixtureClient(t *testing.T) { + index := slices.Index(os.Args, "--") + if index < 0 { + return + } + args := os.Args[index+1:] + if len(args) < 2 || !filepath.IsAbs(args[0]) { + os.Exit(97) + } + root := filepath.Dir(args[0]) + info, err := os.Lstat(root) + if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 || !strings.HasPrefix(filepath.Base(root), "agent-queue-install-test-") { + os.Exit(97) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != uint32(os.Getuid()) { + os.Exit(97) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", args[0]) + }} + defer transport.CloseIdleConnections() + body, _ := json.Marshal(args[1:]) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://fixture/command", bytes.NewReader(body)) + if err != nil { + os.Exit(97) + } + response, err := (&http.Client{Transport: transport}).Do(request) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(97) + } + var reply managedCaskReply + err = json.NewDecoder(io.LimitReader(response.Body, 65536)).Decode(&reply) + response.Body.Close() + if err != nil || response.StatusCode != http.StatusOK || reply.Code < 0 || reply.Code > 255 { + os.Exit(97) + } + fmt.Print(reply.Output) + os.Exit(reply.Code) +} + +func proveManagedNativeFixture(t *testing.T, owner *notarizedCommandOwner, ctx context.Context, environment []string, root, app string, helpers ...string) { + t.Helper() + // The production preflight correctly rejects a bundle outside Applications. + // Use a byte-preserving private copy for this read-only proof, then remove it + // before Homebrew exercises its own app relocation. + proofApp := filepath.Join(root, "User With Spaces/Applications/Agent Queue.app") + if err := os.MkdirAll(filepath.Dir(proofApp), 0700); err != nil { + t.Fatal(err) + } + copy, err := owner.start(ctx, environment, nil, "/usr/bin/ditto", "--rsrc", "--extattr", "--qtn", "--acl", app, proofApp) + if err != nil { + t.Fatal(err) + } + if output, err := copy.wait(); err != nil { + t.Fatalf("private preflight app copy failed: %v: %s", err, output) + } + for _, helper := range helpers { + before, err := os.ReadFile(filepath.Join(root, "calls")) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + probe, err := owner.start(ctx, environment, nil, helper, "install", "--app-bundle", proofApp, "--preflight", "--json") + if err != nil { + t.Fatal(err) + } + output, err := probe.wait() + var report struct { + Issues []json.RawMessage `json:"issues"` + } + if err != nil || json.Unmarshal(output, &report) != nil || report.Issues == nil || len(report.Issues) != 0 { + t.Fatalf("private managed preflight for %s failed: %v: %s", helper, err, output) + } + after, err := os.ReadFile(filepath.Join(root, "calls")) + if err != nil || strings.Count(string(after), "service-management|status|queue") <= strings.Count(string(before), "service-management|status|queue") || strings.Contains(string(after), "service-management|register|") { + t.Fatalf("packaged helper %s lacks the proven private native-lifecycle seam; no installation attempted: %v", helper, err) + } + } + if err := os.RemoveAll(proofApp); err != nil { + t.Fatal(err) + } +} + +// The test parent owns daemon groups outside Homebrew's per-command groups. +// No code here invokes a real native service command or a real launchctl. +type managedCaskController struct { + mu sync.Mutex + ctx context.Context + home, root string + environment []string + owner *notarizedCommandOwner + statuses map[string]string + processes map[string]*notarizedOwnedCommand +} + +func newManagedCaskController(t *testing.T, root, home string, environment []string, owner *notarizedCommandOwner) *managedCaskController { + t.Helper() + canonical, err := filepath.EvalSymlinks(root) + if err != nil || canonical != root || home != filepath.Join(root, "User With Spaces") || !strings.HasPrefix(filepath.Base(root), "agent-queue-install-test-") { + t.Fatal("managed controller requires the canonical private installer fixture", err) + } + for _, path := range []string{root, home, filepath.Join(root, "tools")} { + info, err := os.Lstat(path) + if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { + t.Fatal("unsafe managed controller directory", path, err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != uint32(os.Getuid()) { + t.Fatal("unowned managed controller directory", path) + } + } + deadline := time.Now().Add(2 * time.Minute) + if limit, ok := t.Deadline(); ok && limit.Add(-10*time.Second).Before(deadline) { + deadline = limit.Add(-10 * time.Second) + } + ctx, cancel := context.WithDeadline(context.Background(), deadline) + t.Cleanup(cancel) + c := &managedCaskController{ctx: ctx, home: home, root: root, environment: environment, owner: owner, + statuses: map[string]string{"queue": "notRegistered", "resources": "notRegistered", "remote": "notRegistered", "login": "notRegistered"}, + processes: map[string]*notarizedOwnedCommand{}} + socket := filepath.Join(root, "native.sock") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(socket, 0600); err != nil { + listener.Close() + t.Fatal(err) + } + server := &http.Server{ReadHeaderTimeout: time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var args []string + if r.Method != http.MethodPost || r.URL.Path != "/command" || json.NewDecoder(io.LimitReader(r.Body, 8192)).Decode(&args) != nil { + http.Error(w, "invalid fixture request", http.StatusBadRequest) + return + } + c.mu.Lock() + defer c.mu.Unlock() + reply := c.command(args) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(reply) + })} + done := make(chan error, 1) + go func() { done <- server.Serve(listener) }() + t.Cleanup(func() { + if err := server.Close(); err != nil { + t.Error(err) + } + if err := <-done; !errors.Is(err, http.ErrServerClosed) { + t.Error(err) + } + c.mu.Lock() + defer c.mu.Unlock() + for _, command := range c.processes { + if err := command.stop(); err != nil { + t.Error(err) + } + } + }) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } + for _, tool := range []string{"service-management", "launchctl"} { + wrapper := "#!/bin/sh\nexec " + quote(executable) + " -test.run='^TestManagedCaskFixtureClient$' -- " + quote(socket) + " " + quote(tool) + " \"$@\"\n" + verificationWrite(t, filepath.Join(root, "tools", tool), wrapper, 0700) + } + return c +} + +func (c *managedCaskController) command(args []string) managedCaskReply { + failure := func(err error) managedCaskReply { return managedCaskReply{Output: err.Error() + "\n", Code: 97} } + if len(args) != 3 { + return failure(errors.New("unsupported private lifecycle arguments")) + } + record, err := os.OpenFile(filepath.Join(c.root, "calls"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return failure(err) + } + _, err = fmt.Fprintln(record, strings.Join(args, "|")) + err = errors.Join(err, record.Close()) + if err != nil { + return failure(err) + } + app := filepath.Join(c.home, "Applications/Agent Queue.app") + if args[0] == "service-management" && args[1] == "prepare-app-replacement" { + if args[2] != app { + return failure(errors.New("foreign app replacement target")) + } + info, err := os.Lstat(app) + if err != nil || !info.IsDir() { + return failure(errors.New("private app replacement target is missing")) + } + body, _ := json.Marshal(map[string]any{"schemaVersion": 1, "app": app, "status": "stopped"}) + return managedCaskReply{Output: string(body) + "\n"} + } + labels := map[string]string{"queue": "io.went.agent-heavy", "resources": "io.went.agent-resources", "remote": "io.went.agent-remote"} + if args[0] == "launchctl" && args[1] == "print" { + if args[2] == fmt.Sprintf("gui/%d/io.went.agent-queue", os.Getuid()) { + return managedCaskReply{Output: fmt.Sprintf("Bad request.\nCould not find service %q in domain for user gui: %d\n", "io.went.agent-queue", os.Getuid()), Code: 113} + } + for service, label := range labels { + if args[2] != fmt.Sprintf("gui/%d/%s", os.Getuid(), label) { + continue + } + if c.statuses[service] != "enabled" { + return managedCaskReply{Output: fmt.Sprintf("Bad request.\nCould not find service %q in domain for user gui: %d\n", label, os.Getuid()), Code: 113} + } + process := c.processes[service] + if process == nil { + return failure(errors.New("enabled private service has no owned process")) + } + select { + case <-process.done: + return failure(errors.New("private canonical process exited before ownership readback")) + default: + } + argument := "daemon" + if service == "resources" { + argument = "scheduler" + } + return managedCaskReply{Output: fmt.Sprintf("program = %s\narguments = {\nagent-heavy\n%s\n}\n", filepath.Join(app, "Contents/MacOS/agent-heavy"), argument)} + } + return failure(errors.New("foreign launchctl target")) + } + action, service := args[1], args[2] + if args[0] != "service-management" || c.statuses[service] == "" { + return failure(errors.New("foreign native target")) + } + switch action { + case "status": + body, _ := json.Marshal(map[string]any{"schemaVersion": 1, "service": service, "status": c.statuses[service]}) + return managedCaskReply{Output: string(body) + "\n"} + case "register": + if service == "remote" { + return failure(errors.New("remote registration is outside this fixture")) + } + if c.statuses[service] == "enabled" { + return failure(errors.New("duplicate private registration")) + } + if service != "login" { + helper := filepath.Join(app, "Contents/MacOS/agent-heavy") + info, err := os.Lstat(helper) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 { + return failure(errors.New("private canonical packaged helper is missing")) + } + log, err := os.Create(filepath.Join(c.root, service+".log")) + if err != nil { + return failure(err) + } + c.owner.t.Cleanup(func() { _ = log.Close() }) + argument := "daemon" + if service == "resources" { + argument = "scheduler" + } + command, err := c.owner.start(c.ctx, c.environment, log, helper, argument) + if err != nil { + return failure(err) + } + c.processes[service] = command + } + c.statuses[service] = "enabled" + case "unregister": + if command := c.processes[service]; command != nil { + if err := command.stop(); err != nil { + return failure(err) + } + delete(c.processes, service) + } + c.statuses[service] = "notRegistered" + default: + return failure(errors.New("unsupported private native action")) + } + return managedCaskReply{} +} + +func TestManagedCaskPrivateNativeLifecycle(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("private notarized installer fixture requires macOS") + } + root, home, environment, owner := notarizedCaskFixture(t, "AAAAAAAAAA") + c := newManagedCaskController(t, root, home, environment, owner) + invoke := func(tool string, code int, args ...string) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + command, err := owner.start(ctx, environment, nil, filepath.Join(root, "tools", tool), args...) + if err != nil { + t.Fatal(err) + } + output, err := command.wait() + actual := 0 + if err != nil { + var exited *exec.ExitError + if !errors.As(err, &exited) { + t.Fatal(err) + } + actual = exited.ExitCode() + } + if actual != code { + t.Fatalf("private %s %v exit=%d, want=%d: %s", tool, args, actual, code, output) + } + return string(output) + } + var status struct { + Status string `json:"status"` + } + if err := json.Unmarshal([]byte(invoke("service-management", 0, "status", "queue")), &status); err != nil || status.Status != "notRegistered" { + t.Fatal("unexpected initial private native status", status, err) + } + invoke("launchctl", 113, "print", fmt.Sprintf("gui/%d/io.went.agent-heavy", os.Getuid())) + invoke("launchctl", 113, "print", fmt.Sprintf("gui/%d/io.went.agent-queue", os.Getuid())) + invoke("launchctl", 97, "print", "system/io.went.agent-heavy") + invoke("launchctl", 97, "bootout", fmt.Sprintf("gui/%d/io.went.agent-heavy", os.Getuid())) + invoke("service-management", 97, "register", "queue") // No canonical helper yet. + invoke("service-management", 97, "register", "remote") + invoke("service-management", 97, "register", "foreign") + helper := filepath.Join(home, "Applications/Agent Queue.app/Contents/MacOS/agent-heavy") + verificationWrite(t, helper, "#!/bin/sh\n[ \"$1\" = daemon ] || exit 98\nexec /bin/sleep 60\n", 0700) + invoke("service-management", 97, "prepare-app-replacement", "/Applications/Agent Queue.app") + invoke("service-management", 0, "prepare-app-replacement", filepath.Join(home, "Applications/Agent Queue.app")) + invoke("service-management", 0, "register", "queue") + invoke("service-management", 97, "register", "queue") + loaded := invoke("launchctl", 0, "print", fmt.Sprintf("gui/%d/io.went.agent-heavy", os.Getuid())) + if !strings.Contains(loaded, "program = "+helper+"\n") || !strings.Contains(loaded, "agent-heavy\ndaemon\n") { + t.Fatal("private ownership readback lost canonical helper", loaded) + } + c.mu.Lock() + process := c.processes["queue"] + c.mu.Unlock() + invoke("service-management", 0, "unregister", "queue") + select { + case <-process.done: + default: + t.Fatal("private native unregistration did not reap its process") + } + invoke("launchctl", 113, "print", fmt.Sprintf("gui/%d/io.went.agent-heavy", os.Getuid())) +} + +// A supplied managed release must fail its trust/runtime gate when it cannot +// qualify. A successful subprocess containing SKIP would reproduce the old gap. +func TestNotarizedHomebrewManagedInputCannotSkipGate(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("notarized release gate requires macOS") + } + root, _, environment, owner := notarizedCaskFixture(t, "AAAAAAAAAA") + payload := filepath.Join(root, "Agent Job Queue Installer.app/Contents/Resources/payload") + verificationWrite(t, filepath.Join(payload, "Agent Queue.app/Contents/Info.plist"), `AgentQueueServiceManagement`, 0600) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + environment = append(environment, "AGENT_QUEUE_TEST_NOTARIZED_PAYLOAD="+payload, "APPLE_TEAM_ID=AAAAAAAAAA") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + command, err := owner.start(ctx, environment, nil, executable, "-test.run=^TestNotarizedHomebrew$", "-test.v") + if err != nil { + t.Fatal(err) + } + output, err := command.wait() + var exited *exec.ExitError + if !errors.As(err, &exited) || exited.ExitCode() == 0 || strings.Contains(string(output), "--- SKIP") || !strings.Contains(string(output), "bash failed") || !strings.Contains(string(output), "macOS signing:") { + t.Fatalf("supplied managed payload did not fail its required trust gate: %v: %s", err, output) + } +} + +// Local packaged integration deliberately has no Developer ID/notarization +// claim. The separate TestNotarizedHomebrew retains every signed release gate. +func TestManagedInstallerPackagedNativeFixture(t *testing.T) { + payload := os.Getenv("AGENT_QUEUE_TEST_INSTALLER_PAYLOAD") + if payload == "" { + t.Skip("packaged installer payload not supplied") + } + if runtime.GOOS != "darwin" || !filepath.IsAbs(payload) { + t.Fatal("packaged installer fixture requires macOS and an absolute payload") + } + marked, err := managedApp(filepath.Join(payload, "Agent Queue.app")) + if err != nil || !marked { + t.Fatal("private managed acceptance requires the marked app", err) + } + root, home, environment, owner := notarizedCaskFixture(t, "AAAAAAAAAA") + native := newManagedCaskController(t, root, home, environment, owner) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + run := func(name string, args ...string) []byte { + t.Helper() + command, err := owner.start(ctx, environment, nil, name, args...) + if err != nil { + t.Fatal(err) + } + output, err := command.wait() + if err != nil { + t.Fatalf("private packaged %s: %v: %s", name, err, output) + } + return output + } + privatePayload := filepath.Join(root, "payload") + run("/usr/bin/ditto", "--rsrc", "--extattr", "--qtn", "--acl", payload, privatePayload) + environment = append(environment, "AGENT_QUEUE_INSTALL_TEST_MODE=1", "AGENT_QUEUE_INSTALL_TEST_MANAGED=private-native-lifecycle", "AGENT_QUEUE_INSTALL_TEST_TOOLS="+filepath.Join(root, "tools")) + app := filepath.Join(privatePayload, "Agent Queue.app") + // This read-only probe prevents running a mutating phase with an old helper + // that lacks the production seam. Native status alone cannot register. + proveManagedNativeFixture(t, owner, ctx, environment, root, app, + filepath.Join(app, "Contents/MacOS/agent-heavy"), filepath.Join(privatePayload, "source/bin/agent-heavy")) + run(filepath.Join(privatePayload, "source/bin/agent-heavy"), "install", "--payload", privatePayload) + cli := filepath.Join(home, ".local/bin/agent-heavy") + run(cli, "status", "--json") + native.mu.Lock() + daemon, scheduler := native.processes["queue"], native.processes["resources"] + native.mu.Unlock() + if daemon == nil || scheduler == nil { + t.Fatal("Explicit installer did not start both real packaged private services") + } + // The explicit installer command has ended. Its parent-owned services must + // still accept a real job with exact arguments and exit status. + job, err := owner.start(ctx, append(slices.Clone(environment), "PACKAGED_TEST_VALUE=fixture-env"), nil, cli, + "run", "--label", "Private managed installer job", "--", "/bin/sh", "-c", `printf '%s|%s|%s\n' "$1" "$2" "$PACKAGED_TEST_VALUE"; exit 7`, "fixture", "argument with spaces", "literal;$HOME") + if err != nil { + t.Fatal(err) + } + output, err := job.wait() + var exited *exec.ExitError + if !errors.As(err, &exited) || exited.ExitCode() != 7 || !strings.Contains(string(output), "argument with spaces|literal;$HOME|fixture-env") { + t.Fatalf("private installer packaged job contract failed: %v: %s", err, output) + } + sentinel := filepath.Join(home, ".config/agent-heavy/credentials.fixture") + verificationWrite(t, sentinel, "private credential sentinel", 0600) + // A scheduler tick can own this lock when removal is requested. Exercise + // that refusal deliberately, then retry only a proven unaccepted operation. + unlock, err := backend.LockPrivate(&backend.Gate{State: filepath.Join(home, ".local/state/agent-heavy")}, "scheduling.lock") + if err != nil { + t.Fatal(err) + } + var released sync.Once + release := func() { released.Do(unlock) } + defer release() + removed := false + for attempt := 0; attempt < 3; attempt++ { + calls, err := os.ReadFile(filepath.Join(root, "calls")) + if err != nil { + t.Fatal(err) + } + identity := filepath.Join(home, ".local/share/agent-heavy/installation.json") + before, err := os.ReadFile(identity) + if err != nil { + t.Fatal(err) + } + wrappers := map[string][]byte{} + for _, name := range []string{"agent-heavy", "agent-remote"} { + path := filepath.Join(home, ".local/bin", name) + wrappers[path], err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + } + command, err := owner.start(ctx, environment, nil, cli, "install", "--app-bundle", filepath.Join(home, "Applications/Agent Queue.app"), "--unregister-services") + if err != nil { + t.Fatal(err) + } + output, err := command.wait() + if err == nil { + if attempt == 0 { + t.Fatal("removal ignored the deliberately held scheduling lock") + } + removed = true + break + } + var exited *exec.ExitError + if !errors.As(err, &exited) || exited.ExitCode() != 1 || !strings.HasPrefix(strings.TrimSpace(string(output)), "Agent Queue installer: Another installer or queue change is running; finish it before trying again") { + t.Fatalf("non-retryable private removal failed: %v: %s", err, output) + } + pending := filepath.Join(home, ".local/share/agent-heavy/install-in-progress.json") + if _, err := os.Lstat(pending); !os.IsNotExist(err) { + t.Fatal("busy removal created pending state; mutation cannot be retried", err) + } + after, err := os.ReadFile(identity) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("busy removal changed completed installation identity", err) + } + for path, before := range wrappers { + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("busy removal changed a command wrapper", path, err) + } + } + afterCalls, err := os.ReadFile(filepath.Join(root, "calls")) + if err != nil || !bytes.HasPrefix(afterCalls, calls) { + t.Fatal("busy removal lost its native action witness", err) + } + delta := string(afterCalls[len(calls):]) + for _, mutation := range []string{"service-management|register|", "service-management|unregister|", "service-management|prepare-app-replacement|"} { + if strings.Contains(delta, mutation) { + t.Fatal("busy removal accepted a native mutation; refusal cannot be retried", delta) + } + } + t.Logf("Proven unaccepted busy removal attempt %d: %s", attempt+1, strings.TrimSpace(string(output))) + release() + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(25 * time.Millisecond): + } + } + if !removed { + t.Fatal("private removal remained busy within its original attempt/deadline bounds") + } + if _, err := os.Stat(filepath.Join(home, "Applications/Agent Queue.app/Contents/MacOS/AgentQueue")); err != nil { + t.Fatal("explicit service removal unexpectedly deleted the app", err) + } + for _, path := range []string{backend.QueueDatabasePath(filepath.Join(home, ".local/state/agent-heavy")), sentinel} { + if info, err := os.Lstat(path); err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { + t.Fatalf("private installer removal lost private data: %s: %v: %v", path, info, err) + } + } + retained, err := os.ReadFile(sentinel) + if err != nil || string(retained) != "private credential sentinel" { + t.Fatal("private installer removal changed retained credentials", err) + } + for _, command := range []*notarizedOwnedCommand{daemon, scheduler} { + select { + case <-command.done: + default: + t.Fatal("private installer removal did not stop and reap its real packaged services") + } + } + for _, name := range []string{".local/bin/agent-heavy", ".local/bin/agent-remote", ".local/share/agent-heavy/installation.json"} { + if _, err := os.Lstat(filepath.Join(home, name)); !os.IsNotExist(err) { + t.Fatalf("private installer removal retained generated location/wrapper %s: %v", name, err) + } + } + t.Log("Actual packaged helpers passed explicit installer setup and private runtime acceptance; native lifecycle was injected, actual OS registration remains unqualified") +} diff --git a/internal/tooling/notarized_cask_test.go b/internal/tooling/notarized_cask_test.go index 0f9fea8..5a9d688 100644 --- a/internal/tooling/notarized_cask_test.go +++ b/internal/tooling/notarized_cask_test.go @@ -13,6 +13,7 @@ import ( "path/filepath" "regexp" "runtime" + "slices" "strconv" "strings" "sync" @@ -38,6 +39,11 @@ func TestNotarizedHomebrew(t *testing.T) { if err != nil { t.Fatal(err) } + app := filepath.Join(payload, "Agent Queue.app") + managed, err := managedApp(app) + if err != nil { + t.Fatal(err) + } fixture, home, environment, owner := notarizedCaskFixture(t, team) parent := context.Background() if deadline, ok := t.Deadline(); ok { @@ -83,39 +89,47 @@ func TestNotarizedHomebrew(t *testing.T) { if err != nil { t.Fatal(err) } + var native *managedCaskController + if managed { + native = newManagedCaskController(t, fixture, home, environment, owner) + + } cask := notarizedFixtureCask(t, fixture, identity.Version) cancelVerification() // Trust verification has its own deadline. Its duration must not consume // the installation phase's reserved Homebrew command and cleanup budget. ctx, cancel := context.WithTimeout(parent, 2*time.Minute) t.Cleanup(cancel) - log, err := os.Create(filepath.Join(fixture, "daemon.log")) - if err != nil { - t.Fatal(err) - } - daemon, err := owner.start(ctx, environment, log, executable, "daemon") - if err != nil { - log.Close() - t.Fatal(err) - } - t.Cleanup(func() { _ = log.Close() }) - ready := false - for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); { - probeCtx, stop := context.WithTimeout(ctx, time.Second) - probe, err := owner.start(probeCtx, environment, nil, executable, "status", "--json") - if err == nil { - _, err = probe.wait() + var daemon *notarizedOwnedCommand + if !managed { + log, err := os.Create(filepath.Join(fixture, "daemon.log")) + if err != nil { + t.Fatal(err) } - stop() - if err == nil { - ready = true - break + daemon, err = owner.start(ctx, environment, log, executable, "daemon") + if err != nil { + log.Close() + t.Fatal(err) + } + t.Cleanup(func() { _ = log.Close() }) + ready := false + for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); { + probeCtx, stop := context.WithTimeout(ctx, time.Second) + probe, err := owner.start(probeCtx, environment, nil, executable, "status", "--json") + if err == nil { + _, err = probe.wait() + } + stop() + if err == nil { + ready = true + break + } + time.Sleep(100 * time.Millisecond) + } + if !ready { + output, _ := os.ReadFile(filepath.Join(fixture, "daemon.log")) + t.Fatalf("private signed queue daemon did not become ready: %s", output) } - time.Sleep(100 * time.Millisecond) - } - if !ready { - output, _ := os.ReadFile(filepath.Join(fixture, "daemon.log")) - t.Fatalf("private signed queue daemon did not become ready: %s", output) } brew, err := exec.LookPath("brew") if err != nil { @@ -135,7 +149,7 @@ func TestNotarizedHomebrew(t *testing.T) { } lines := strings.Split(strings.TrimSpace(string(output)), "\n") if err := json.Unmarshal([]byte(lines[len(lines)-1]), &result); err != nil || !result.AppInstalled || result.Executed { - t.Fatalf("real cask did not relocate the app without running setup: %v\n%s", err, output) + t.Fatalf("real cask did not deliver only the explicit installer: %v\n%s", err, output) } if result.Attrs["entries"] == 0 || len(result.Attrs) != 5 { t.Fatalf("missing staged attribute evidence: %v", result.Attrs) @@ -145,20 +159,26 @@ func TestNotarizedHomebrew(t *testing.T) { t.Fatalf("staged %s was not retained: %v", name, result.Attrs) } } - // Homebrew installs the app artifact without running setup in its restricted - // install-hook sandbox. First launch invokes this exact signed helper request - // through QueueBootstrapEnvironment and presents any failure with Retry. - // Exercise that request directly in the private home, without opening a menu - // app in the operator's graphical session. - app := filepath.Join(home, "Applications/Agent Queue.app") - if _, err := os.Stat(filepath.Join(home, ".local/share/agent-heavy/build-info.json")); !os.IsNotExist(err) { - t.Fatal("cask unexpectedly initialized the private backend") - } - // Ruby previously rehydrated these fixture-only settings. The native launch - // request now runs separately, so explicitly keep every service command on - // the private launchctl stub rather than the operator's login services. - run(ctx, "/usr/bin/env", "AGENT_QUEUE_INSTALL_TEST_MODE=1", "AGENT_QUEUE_INSTALL_TEST_TOOLS="+filepath.Join(fixture, "tools"), - filepath.Join(app, "Contents/Resources/payload/source/bin/agent-heavy"), "install", "--app-bundle", app) + if calls, err := os.ReadFile(filepath.Join(fixture, "calls")); err == nil && len(calls) != 0 || err != nil && !os.IsNotExist(err) { + t.Fatalf("Homebrew delivery called queue/native lifecycle: %v: %s", err, calls) + } + // Explicit setup occurs outside Homebrew and its sandbox, only after + // delivery and quarantine readback have completed successfully. + deliveredPayload := filepath.Join(home, "Applications/Agent Job Queue Installer.app/Contents/Resources/payload") + runtimeEnvironment := append(slices.Clone(environment), "AGENT_QUEUE_INSTALL_TEST_MODE=1", "AGENT_QUEUE_INSTALL_TEST_TOOLS="+filepath.Join(fixture, "tools")) + if managed { + runtimeEnvironment = append(runtimeEnvironment, "AGENT_QUEUE_INSTALL_TEST_MANAGED=private-native-lifecycle") + deliveredApp := filepath.Join(deliveredPayload, "Agent Queue.app") + proveManagedNativeFixture(t, owner, ctx, runtimeEnvironment, fixture, deliveredApp, + filepath.Join(deliveredApp, "Contents/MacOS/agent-heavy"), filepath.Join(deliveredPayload, "source/bin/agent-heavy")) + } + setup, err := owner.start(ctx, runtimeEnvironment, nil, filepath.Join(deliveredPayload, "source/bin/agent-heavy"), "install", "--payload", deliveredPayload) + if err != nil { + t.Fatal(err) + } + if output, err := setup.wait(); err != nil { + t.Fatalf("explicit signed private installer setup failed outside Homebrew: %v: %s", err, output) + } installed, err := os.ReadFile(filepath.Join(home, ".local/share/agent-heavy/build-info.json")) if err != nil { t.Fatal(err) @@ -167,7 +187,21 @@ func TestNotarizedHomebrew(t *testing.T) { if err != nil || !identity.Equal(installedIdentity) { t.Fatalf("real installed identity differs: %v", err) } - for _, name := range []string{"Applications/Agent Queue.app/Contents/MacOS/AgentQueue", ".local/share/agent-heavy/bin/agent-heavy", ".local/bin/agent-remote", "Library/LaunchAgents/io.went.agent-resources.plist"} { + expectedFiles := []string{"Applications/Agent Queue.app/Contents/MacOS/AgentQueue", ".local/bin/agent-remote"} + installedHeavy := filepath.Join(home, ".local/share/agent-heavy/bin/agent-heavy") + if managed { + expectedFiles = append(expectedFiles, "Applications/Agent Queue.app/Contents/MacOS/agent-heavy", "Applications/Agent Queue.app/Contents/MacOS/agent-remote", ".local/share/agent-heavy/installation.json") + installedHeavy = filepath.Join(home, "Applications/Agent Queue.app/Contents/MacOS/agent-heavy") + native.mu.Lock() + daemon = native.processes["queue"] + native.mu.Unlock() + if daemon == nil { + t.Fatal("managed fixture did not start the actual packaged canonical daemon") + } + } else { + expectedFiles = append(expectedFiles, ".local/share/agent-heavy/bin/agent-heavy", "Library/LaunchAgents/io.went.agent-resources.plist") + } + for _, name := range expectedFiles { if _, err := os.Stat(filepath.Join(home, name)); err != nil { t.Fatalf("missing real installed file %s: %v", name, err) } @@ -175,17 +209,38 @@ func TestNotarizedHomebrew(t *testing.T) { if _, err := os.Stat(filepath.Join(home, ".local/share/agent-heavy/install-in-progress.json")); !os.IsNotExist(err) { t.Fatal("real installation did not finish its transaction") } - run(ctx, filepath.Join(home, ".local/share/agent-heavy/bin/agent-heavy"), "status", "--json") + run(ctx, installedHeavy, "status", "--json") select { case <-daemon.done: t.Fatalf("installation stopped the private daemon: %v", daemon.waitErr) default: } calls, err := os.ReadFile(filepath.Join(fixture, "calls")) - if err != nil || strings.Contains(string(calls), "UNEXPECTED") || !strings.Contains(string(calls), "bootstrap") { + expectedLifecycle := "bootstrap" + if managed { + expectedLifecycle = "service-management|register|resources" + } + if err != nil || strings.Contains(string(calls), "UNEXPECTED") || !strings.Contains(string(calls), expectedLifecycle) { t.Fatalf("private launchctl boundary was not exercised safely: %v\n%s", err, calls) } - t.Logf("Real quarantined app relocation and signed first-launch setup completed; %d entries retain both attributes and the private daemon remains healthy", result.Attrs["entries"]) + if managed { + jobEnvironment := append(slices.Clone(environment), "PACKAGED_TEST_VALUE=fixture-env") + job, err := owner.start(ctx, jobEnvironment, nil, installedHeavy, "run", "--label", "Quarantined packaged fixture", "--", "/bin/sh", "-c", `printf '%s|%s|%s\n' "$1" "$2" "$PACKAGED_TEST_VALUE"; exit 7`, "fixture", "argument with spaces", "literal;$HOME") + if err != nil { + t.Fatal(err) + } + output, err := job.wait() + var exited *exec.ExitError + if !errors.As(err, &exited) || exited.ExitCode() != 7 || !strings.Contains(string(output), "argument with spaces|literal;$HOME|fixture-env") { + t.Fatalf("signed packaged private job lost argv, environment or exit status: %v: %s", err, output) + } + database := filepath.Join(home, ".local/state/agent-heavy/queue.sqlite3") + if info, err := os.Lstat(database); err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { + t.Fatalf("signed packaged queue DB is not private: %v: %v", info, err) + } + t.Log("Native lifecycle and loaded-service readback were injected; actual OS ServiceManagement registration remains UNQUALIFIED and needs an isolated login-session gate") + } + t.Logf("Real quarantined installer delivery and separate explicit signed private setup completed; %d entries retain both attributes and the private daemon remains healthy", result.Attrs["entries"]) } func TestNotarizedHomebrewRejectsUnsignedPayload(t *testing.T) { @@ -475,6 +530,10 @@ func notarizedCaskFixture(t *testing.T, team string) (string, string, []string, } else if !os.IsNotExist(err) { owner.retain = true } + if t.Failed() { + t.Logf("private cask failure evidence retained at %s", fixture) + return + } if owner.retain { t.Errorf("command cleanup was not proven; private fixture tools retained at %s", fixture) return diff --git a/internal/tooling/signing_resume_test.go b/internal/tooling/signing_resume_test.go index 41c3e77..94e27e3 100644 --- a/internal/tooling/signing_resume_test.go +++ b/internal/tooling/signing_resume_test.go @@ -90,8 +90,7 @@ func TestMacOSResumeRejectsDeclaredMissingPayload(t *testing.T) { if err := os.RemoveAll(f.state); err != nil { t.Fatal(err) } - f.command(t, nil, "init", f.installer, f.state, "0.1.0") - f.reject(t, nil, "declares an embedded setup payload but it is missing", "prepare", f.state, "payload", "0.1.0") + f.reject(t, nil, "declares an embedded setup payload but it is missing", "init", f.installer, f.state, "0.1.0") if _, err := os.Stat(filepath.Join(f.state, "stages/payload.json")); !os.IsNotExist(err) { t.Fatalf("missing payload created a prepared signing stage: %v", err) } @@ -875,3 +874,26 @@ func TestMacOSResumeBindsExactOriginalArtifactAndTeam(t *testing.T) { }) } } + +func TestMacOSResumeManagedHelpersRemainInsidePayloadCheckpoint(t *testing.T) { + f := newResumeFixture(t) + f.embedManagedApp(t) + if err := os.RemoveAll(f.state); err != nil { + t.Fatal(err) + } + f.command(t, nil, "init", f.installer, f.state, "0.1.0") + for _, stage := range []string{"payload", "installer", "dmg"} { + for _, operation := range []string{"prepare", "submit", "finish"} { + f.step(t, operation, stage) + } + } + record := f.recorded(t) + for _, name := range []string{"agent-heavy", "agent-remote"} { + if !strings.Contains(record, "Agent Queue.app/Contents/MacOS/"+name) { + t.Fatalf("resumed release omitted canonical helper: %s", record) + } + } + if !strings.Contains(record, "disk-image-layout|Agent Job Queue Installer.app|explicit-setup\n") { + t.Fatal("managed resume lost explicit installer disk image layout") + } +} diff --git a/internal/tooling/signing_test.go b/internal/tooling/signing_test.go index ab903e8..10ce7aa 100644 --- a/internal/tooling/signing_test.go +++ b/internal/tooling/signing_test.go @@ -561,6 +561,9 @@ case "$tool" in esac ;; plutil) + if [[ $1 == -convert || $1 == -type || ( $1 == -extract && $2 == AgentQueueServiceManagement ) ]]; then + exec /usr/bin/plutil "$@" + fi value=$(sed -n "s/.*\"$2\":\"\([^\"]*\)\".*/\1/p" "$last") [[ -n $value ]] || exit 1 printf '%s\n' "$value" @@ -586,6 +589,11 @@ case "$tool" in [[ -L "$image_source/Applications" && $(readlink "$image_source/Applications") == /Applications ]] [[ ! -e "$image_source/Agent Job Queue Installer.app" ]] printf 'disk-image-layout|Agent Queue.app|Applications->/Applications\n' >> "$SIGNING_TEST_EVENTS" + elif [[ -f "$image_source/Contents/MacOS/Installer" ]]; then + [[ ! -e "$image_source/Applications" && ! -e "$image_source/Agent Queue.app" ]] + printf 'disk-image-layout|Agent Job Queue Installer.app|explicit-setup\n' >> "$SIGNING_TEST_EVENTS" + else + exit 91 fi printf 'disk image' > "$last" fi @@ -597,3 +605,88 @@ case "$tool" in *) exit 90;; esac ` + +func (f signingFixture) embedManagedApp(t *testing.T) string { + t.Helper() + source := f.embedAppSupport(t) + app := filepath.Join(f.installer, "Contents/Resources/payload/Agent Queue.app") + verificationWrite(t, filepath.Join(app, "Contents/Info.plist"), `AgentQueueServiceManagement`, 0600) + for _, name := range []string{"agent-heavy", "agent-remote"} { + if err := os.Rename(filepath.Join(source, "bin", name), filepath.Join(app, "Contents/MacOS", name)); err != nil { + t.Fatal(err) + } + } + if err := os.Remove(filepath.Join(source, "bin")); err != nil { + t.Fatal(err) + } + for _, name := range []string{"heavy", "resources", "remote"} { + filename := "io.went.agent-" + name + ".plist" + raw, err := os.ReadFile(filepath.Join("../../native/LaunchAgents", filename)) + if err != nil { + t.Fatal(err) + } + verificationWrite(t, filepath.Join(app, "Contents/Library/LaunchAgents", filename), string(raw), 0600) + } + return app +} + +func TestMacOSSigningManagedHelpersInsideOut(t *testing.T) { + f := newSigningFixture(t) + app := f.embedManagedApp(t) + if output, err := f.run(t, signingProductionEnvironment(), "package", f.installer, f.destination, "1.2.3"); err != nil { + t.Fatalf("managed signing: %v: %s", err, output) + } + record := f.recorded(t) + seal := strings.Index(record, "codesign|--force|--sign|Developer ID Application: Fixture ("+signingTestTeam+")|--timestamp|--options|runtime|"+app+"\n") + for _, name := range []string{"agent-heavy", "agent-remote"} { + path := filepath.Join(app, "Contents/MacOS", name) + sign := strings.Index(record, "|--identifier|io.went.agent-queue."+name+"|"+path+"\n") + if sign < 0 || seal < sign { + t.Fatalf("canonical helper not sealed before app: %s", record) + } + for _, arch := range []string{"arm64", "x86_64"} { + if !strings.Contains(record, "codesign|--display|--verbose=4|--architecture|"+arch+"|"+path+"\n") { + t.Fatalf("canonical helper not verified: %s", record) + } + } + } + if !strings.Contains(record, "disk-image-layout|Agent Job Queue Installer.app|explicit-setup\n") || strings.Contains(record, "Applications->/Applications") { + t.Fatalf("managed release lost explicit installer layout: %s", record) + } + assertNotarySubmissionNames(t, record, "1.2.3") +} + +func TestMacOSSigningRejectsManagedLayoutTampering(t *testing.T) { + for _, mutation := range []string{"duplicate bin", "missing helper", "missing plist", "modified plist", "extra login plist", "false marker"} { + t.Run(mutation, func(t *testing.T) { + f := newSigningFixture(t) + app := f.embedManagedApp(t) + path := filepath.Join(app, "Contents/Library/LaunchAgents/io.went.agent-heavy.plist") + switch mutation { + case "duplicate bin": + verificationWrite(t, filepath.Join(app, "Contents/Resources/payload/source/bin/agent-heavy"), "duplicate", 0700) + case "missing helper": + if err := os.Remove(filepath.Join(app, "Contents/MacOS/agent-remote")); err != nil { + t.Fatal(err) + } + case "missing plist": + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + case "modified plist": + raw, _ := os.ReadFile(path) + verificationWrite(t, path, strings.Replace(string(raw), "63", "0", 1), 0600) + case "extra login plist": + verificationWrite(t, filepath.Join(filepath.Dir(path), "io.went.agent-queue.plist"), "login", 0600) + case "false marker": + verificationWrite(t, filepath.Join(app, "Contents/Info.plist"), `AgentQueueServiceManagement`, 0600) + } + if output, err := f.run(t, signingProductionEnvironment(), "package", f.installer, f.destination, "1.2.3"); err == nil { + t.Fatalf("managed tampering accepted: %s", output) + } + if strings.Contains(f.recorded(t), "notarytool|submit") { + t.Fatal("tampered managed app reached Apple submission") + } + }) + } +} diff --git a/internal/tooling/tooling.go b/internal/tooling/tooling.go index 4a8b383..14d4caa 100644 --- a/internal/tooling/tooling.go +++ b/internal/tooling/tooling.go @@ -388,28 +388,20 @@ const caskTemplate = `cask "agent-job-queue" do # Preserve quarantine so macOS can assess the notarized release. - application = "#{Dir.home}/Applications/Agent Queue.app" - app "Agent Job Queue Installer.app/Contents/Resources/payload/Agent Queue.app", target: application + # Homebrew owns delivery of the explicit installer, never the running app. + app "Agent Job Queue Installer.app", target: "#{Dir.home}/Applications/Agent Job Queue Installer.app" - # Stable per-user wrappers survive staged cask cleanup and future upgrades. binary "Agent Job Queue Installer.app/Contents/Resources/payload/source/links/agent-heavy" binary "Agent Job Queue Installer.app/Contents/Resources/payload/source/links/agent-remote" - # The app artifact owns app removal and upgrade rollback. - uninstall quit: "io.went.agent-queue", - script: { - executable: "/bin/rm", - args: ["-f", "--", "#{Dir.home}/Library/LaunchAgents/io.went.agent-queue.plist"], - sudo: false, - } - caveats <<~EOS - Installs Agent Queue for this account without Python, uv, or a Go toolchain. - Open Agent Queue to finish setup. Command-line tools are ready after setup completes. - Release installer and app are Developer ID signed and notarized. - Quarantine is retained. Phone access is off until enabled. - Uninstall preserves the independent queue, jobs, credentials and remote access. - Disable phone access in settings before uninstall to stop its HTTPS service. + Open Agent Job Queue Installer in this account's Applications folder and choose Install. + Installation, upgrades and service approval happen in that explicit installer. + Finish queued and running jobs first. CLI links become ready after setup. + Homebrew delivers the installer; its sandbox cannot manage the queue's services. + Homebrew upgrade or uninstall leaves the installed Agent Queue app and services in place. + To remove Agent Queue, use its explicit service-removal command before deleting the app. + Release installer and app are Developer ID signed and notarized. Quarantine is retained. EOS end ` diff --git a/internal/tooling/tooling_test.go b/internal/tooling/tooling_test.go index dd419d1..7e9f4dc 100644 --- a/internal/tooling/tooling_test.go +++ b/internal/tooling/tooling_test.go @@ -76,12 +76,12 @@ func TestHomebrewManifestAndCaskContracts(t *testing.T) { if err != nil { t.Fatal(err) } - for _, expected := range []string{manifest.SHA256, "source/links/agent-heavy", "source/links/agent-remote", `app "Agent Job Queue Installer.app/Contents/Resources/payload/Agent Queue.app", target: application`, `args: ["-f", "--", "#{Dir.home}/Library/LaunchAgents/io.went.agent-queue.plist"]`, "Open Agent Queue to finish setup", "Command-line tools are ready after setup completes", "Developer ID signed and notarized", "Quarantine is retained", "sudo: false"} { + for _, expected := range []string{manifest.SHA256, "source/links/agent-heavy", "source/links/agent-remote", `app "Agent Job Queue Installer.app", target: "#{Dir.home}/Applications/Agent Job Queue Installer.app"`, "Open Agent Job Queue Installer", "choose Install", "CLI links become ready after setup", "Homebrew upgrade or uninstall leaves the installed Agent Queue app and services in place", "Developer ID signed and notarized", "Quarantine is retained"} { if !strings.Contains(body, expected) { t.Errorf("missing %s", expected) } } - for _, obsolete := range []string{"depends_on formula:", "sudo: true", "preflight", "/usr/bin/xattr", "com.apple.quarantine", "source/scripts/install-user.sh", "installer script:", "postflight", "system_command", `"--payload"`, `delete: [`, `"#{Dir.home}/Applications/Agent Queue.app",`} { + for _, obsolete := range []string{"depends_on formula:", "sudo:", "preflight", "postflight", "/usr/bin/xattr", "com.apple.quarantine", "source/scripts/install-user.sh", "installer script:", "system_command", `"--payload"`, `"--app-bundle"`, "uninstall quit:", "uninstall script:", "run \"", `delete: [`, `"#{Dir.home}/Applications/Agent Queue.app",`, "payload/Agent Queue.app\", target:"} { if strings.Contains(body, obsolete) { t.Errorf("obsolete dependency %s", obsolete) } diff --git a/native/AgentBootstrap.swift b/native/AgentBootstrap.swift index 763c8e7..fe58e2f 100644 --- a/native/AgentBootstrap.swift +++ b/native/AgentBootstrap.swift @@ -1,6 +1,7 @@ import AppKit import Darwin import Foundation +import ServiceManagement import SwiftUI // This is a read-only startup decision. The backend remains the authority for @@ -19,8 +20,12 @@ struct QueueBootstrapEnvironment: Sendable { var root: URL { home.appendingPathComponent(".local/share/agent-heavy") } var source: URL { resources.appendingPathComponent("payload/source") } + var allowsInstallation: Bool { arguments.filter { $0 == "--setup" }.count == 1 } func validateDiagnostics() throws { + if arguments.filter({ $0 == "--setup" }).count > 1 || arguments.contains(where: { $0.hasPrefix("--setup=") }) { + throw ClientError.message("Use --setup once to open the explicit installation workflow.") + } let fixtureIndices = arguments.indices.filter { arguments[$0] == "--fixture" } if !fixtureIndices.isEmpty || arguments.contains(where: { $0.hasPrefix("--fixture=") }) { guard fixtureIndices.count == 1, let index = fixtureIndices.first, @@ -52,7 +57,9 @@ struct QueueBootstrapEnvironment: Sendable { let bundleScope = QueueBootstrapFiles.Scope(root: app, bundled: true) try QueueBootstrapFiles.validateFile(infoURL, scope: bundleScope) let payloadExists = Self.exists(resources.appendingPathComponent("payload")) - let marked = (try? Self.readPlist(QueueBootstrapPackaging.self, at: infoURL, scope: bundleScope))?.embeddedPayload == true + let packaging = try? Self.readPlist(QueueBootstrapPackaging.self, at: infoURL, scope: bundleScope) + let managed = packaging?.serviceManagement == true + let marked = packaging?.embeddedPayload == true || managed guard payloadExists || marked else { return .legacy } let info = try? Self.readPlist(QueueBootstrapBundle.self, at: infoURL, scope: bundleScope) @@ -80,7 +87,7 @@ struct QueueBootstrapEnvironment: Sendable { throw ClientError.message("App and payload build identities differ.") } for name in ["agent-heavy", "agent-remote"] { - let binary = source.appendingPathComponent("bin/\(name)") + let binary = managed ? app.appendingPathComponent("Contents/MacOS/\(name)") : source.appendingPathComponent("bin/\(name)") guard try QueueBootstrapFiles.safeExecutable(binary, scope: bundleScope) else { throw ClientError.message("The bundled \(name) executable is missing or unsafe.") } @@ -88,7 +95,7 @@ struct QueueBootstrapEnvironment: Sendable { } catch { throw ClientError.message("Agent Queue's embedded setup payload is incomplete or invalid. Download the app again. \(error.localizedDescription)") } - let request = QueueBootstrapRequest(executable: source.appendingPathComponent("bin/agent-heavy"), + let request = QueueBootstrapRequest(executable: managed ? app.appendingPathComponent("Contents/MacOS/agent-heavy") : source.appendingPathComponent("bin/agent-heavy"), arguments: ["install", "--app-bundle", app.path]) let cli = home.appendingPathComponent(".local/bin") let launch = home.appendingPathComponent("Library/LaunchAgents") @@ -102,7 +109,7 @@ struct QueueBootstrapEnvironment: Sendable { home.appendingPathComponent(".local/state/agent-heavy"), home.appendingPathComponent(".config/agent-heavy")] { try QueueBootstrapFiles.validateDirectory(directory, scope: .init(root: directory)) } - for name in ["build-info.json", "install-in-progress.json", "bin/agent-heavy", "bin/agent-remote"] { + for name in ["build-info.json", "install-in-progress.json", "installation.json"] + (managed ? [] : ["bin/agent-heavy", "bin/agent-remote"]) { try QueueBootstrapFiles.validateFile(root.appendingPathComponent(name), scope: privateScope) } for name in ["agent-heavy", "agent-remote"] { @@ -117,6 +124,31 @@ struct QueueBootstrapEnvironment: Sendable { guard let installed = try? Self.readIdentity(root.appendingPathComponent("build-info.json"), scope: privateScope), installed == identity else { return .install(request, "Setting up this version for your macOS account…") } + if managed { + guard let installation = try? JSONDecoder().decode(QueueBootstrapInstallation.self, + from: Self.read(root.appendingPathComponent("installation.json"), scope: privateScope)), + installation.schemaVersion == 1, installation.serviceManagement, + installation.app == app.path else { + return .install(request, "The installed application location needs setup.") + } + for name in ["agent-heavy", "agent-remote"] { + guard try QueueBootstrapFiles.safeExecutable(cli.appendingPathComponent(name), scope: cliScope) else { + return .install(request, "The command entry points need setup.") + } + } + for (label, helper, command) in [("io.went.agent-heavy", "agent-heavy", "daemon"), + ("io.went.agent-resources", "agent-heavy", "scheduler"), + ("io.went.agent-remote", "agent-remote", "serve")] { + let definition = app.appendingPathComponent("Contents/Library/LaunchAgents/\(label).plist") + guard let service = try? Self.readPlist(QueueBootstrapManagedLaunch.self, at: definition, scope: bundleScope), + service.label == label, service.bundleProgram == "Contents/MacOS/\(helper)", + service.program == nil, + service.arguments == [helper, command] else { + throw ClientError.message("A bundled background service definition is missing or invalid. Download a complete installer.") + } + } + return .ready + } for name in ["agent-heavy", "agent-remote"] { guard try QueueBootstrapFiles.safeExecutable(root.appendingPathComponent("bin/\(name)"), scope: privateScope) else { return .install(request, "Repairing missing backend components…") @@ -146,6 +178,21 @@ struct QueueBootstrapEnvironment: Sendable { return .ready } + func serviceStatusRequests() throws -> [QueueBootstrapRequest] { + guard !arguments.contains("--fixture"), Self.exists(app.appendingPathComponent("Contents/Info.plist")) else { return [] } + let scope = QueueBootstrapFiles.Scope(root: app, bundled: true) + let packaging = try Self.readPlist(QueueBootstrapPackaging.self, + at: app.appendingPathComponent("Contents/Info.plist"), scope: scope) + guard packaging.serviceManagement == true else { return [] } + let executable = app.appendingPathComponent("Contents/MacOS/AgentQueue") + guard try QueueBootstrapFiles.safeExecutable(executable, scope: scope) else { + throw ClientError.message("The app launcher is missing or unsafe. Download a complete installer.") + } + return ["queue", "resources"].map { + QueueBootstrapRequest(executable: executable, arguments: ["--service-management", "status", $0]) + } + } + private static func exists(_ url: URL) -> Bool { var value = stat() return lstat(url.path, &value) == 0 @@ -273,7 +320,8 @@ enum QueueBootstrapFiles { defer { close(descriptor) } // Respect the account's effective owner/group permissions rather than // accepting an execute bit that applies only to another class of user. - return faccessat(directory, url.lastPathComponent, X_OK, 0) == 0 + return faccessat(directory, url.lastPathComponent, X_OK, 0) == 0 && + QueueExecutableNamespace.allows(executable: url, root: scope.root) } } @@ -287,7 +335,33 @@ private struct QueueBootstrapIdentity: Decodable, Equatable { private struct QueueBootstrapPackaging: Decodable { let embeddedPayload: Bool? - enum CodingKeys: String, CodingKey { case embeddedPayload = "AgentQueueEmbeddedPayload" } + let serviceManagement: Bool? + enum CodingKeys: String, CodingKey { + case embeddedPayload = "AgentQueueEmbeddedPayload" + case serviceManagement = "AgentQueueServiceManagement" + } +} + +private struct QueueBootstrapInstallation: Decodable { + let schemaVersion: Int + let app: String + let serviceManagement: Bool +} + +private struct QueueBootstrapManagedLaunch: Decodable { + let label: String + let bundleProgram: String + let arguments: [String] + let program: String? + enum CodingKeys: String, CodingKey { + case label = "Label", bundleProgram = "BundleProgram", arguments = "ProgramArguments", program = "Program" + } +} + +private struct QueueBootstrapServiceState: Decodable { + let schemaVersion: Int + let service: String + let status: String } private struct QueueBootstrapBundle: Decodable { @@ -399,16 +473,18 @@ struct QueueBootstrapReport: Codable, Equatable, Sendable { @MainActor final class QueueBootstrapModel: ObservableObject { typealias Executor = @Sendable (QueueBootstrapRequest, @escaping @Sendable (Data) -> Void) async throws -> Data - enum Phase { case idle, checking, reviewing, waiting, recovering, installing, failed, complete } + enum Phase { case idle, checking, setupRequired, reviewing, waiting, recovering, installing, failed, complete } @Published private(set) var phase = Phase.idle @Published private(set) var message = "Checking setup…" @Published private(set) var output = "" @Published private(set) var issues: [QueueBootstrapIssue] = [] @Published private(set) var reviewedIssueID: String? @Published private(set) var canDisablePhoneAccess = false + @Published private(set) var requiresServiceApproval = false private static let phoneAccessRecovery = "AGENT_QUEUE_SETUP_RECOVERY:disable-phone-access" var isRunning: Bool { [.checking, .waiting, .recovering, .installing].contains(phase) } var canStopWaiting: Bool { phase == .waiting || phase == .checking } + var allowsInstallation: Bool { environment.allowsInstallation } var onSetupRequired: (() -> Void)? private let environment: QueueBootstrapEnvironment private let inspect: @Sendable () throws -> QueueBootstrapDecision @@ -437,11 +513,12 @@ final class QueueBootstrapModel: ObservableObject { nonisolated static func runInstaller(_ request: QueueBootstrapRequest, output: @escaping @Sendable (Data) -> Void) async throws -> Data { try await QueueClient.run(request.arguments, budget: .setup, - transport: .init(executable: request.executable, timeout: 330), onOutput: output) + transport: .init(executable: request.executable, timeout: QueueClient.timeout(for: request.arguments, budget: .setup)), onOutput: output) } @discardableResult func start(disablePhoneAccess: Bool = false) -> Task? { guard !isRunning, phase != .complete else { return nil } + guard !disablePhoneAccess || allowsInstallation else { return nil } if disablePhoneAccess && !legacyPhoneAccess { guard let issue = issues.first(where: { $0.actions.contains(where: { $0.id == "disable-phone-access" }) }) else { return nil } return perform(actionID: "disable-phone-access", issueID: issue.id, confirmed: true) @@ -450,12 +527,19 @@ final class QueueBootstrapModel: ObservableObject { let current = begin(.checking, message: "Checking installation requirements…") issues = [] request = nil + requiresServiceApproval = false let task = Task { [self] in do { let decision = try await inspectDecision(current) if case let .install(request, reason) = decision { self.request = request onSetupRequired?() + guard allowsInstallation else { + phase = .setupRequired + message = "Complete installation before opening the queue" + output = "Open Agent Job Queue Installer and choose Install. An incomplete installation or a different app version needs an explicit installation step." + return + } if disablePhoneAccess { legacyPhoneAccess = false try await install(QueueBootstrapRequest(executable: request.executable, @@ -464,6 +548,25 @@ final class QueueBootstrapModel: ObservableObject { try await install(request, reason: reason, current: current) } } else { + for statusRequest in try environment.serviceStatusRequests() { + let data = try await execute(statusRequest) { _ in } + guard data.count <= 16_384 else { throw ClientError.message("Background service status exceeded its response limit.") } + let state = try JSONDecoder().decode(QueueBootstrapServiceState.self, from: data) + guard state.schemaVersion == 1, state.service == statusRequest.arguments.last, + ["notRegistered", "enabled", "requiresApproval", "notFound"].contains(state.status) else { + throw ClientError.message("Background service status is invalid.") + } + guard state.status == "enabled" else { + requiresServiceApproval = state.status == "requiresApproval" + phase = .setupRequired + message = requiresServiceApproval ? "Allow background services in System Settings" : "Complete background service setup" + output = requiresServiceApproval + ? "Open Login Items settings, allow Agent Queue's background services, then check again." + : "Open Agent Job Queue Installer and choose Install to register this app's background services." + onSetupRequired?() + return + } + } finish(current) } } catch { await fail(error, current: current) } @@ -473,7 +576,7 @@ final class QueueBootstrapModel: ObservableObject { } @discardableResult func perform(actionID: String, issueID: String, jobID: Int? = nil, confirmed: Bool = false) -> Task? { - guard !isRunning, phase != .complete, let issue = issues.first(where: { $0.id == issueID }), + guard allowsInstallation, !isRunning, phase != .complete, let issue = issues.first(where: { $0.id == issueID }), let action = issue.actions.first(where: { $0.id == actionID }), QueueBootstrapAction.executable.contains(actionID), !action.needsConfirmation || confirmed else { return nil } let needsJob = actionID == "cancel-job" || actionID == "cleanup-job" @@ -503,7 +606,7 @@ final class QueueBootstrapModel: ObservableObject { } @discardableResult func waitAndContinue() -> Task? { - guard !isRunning, phase != .complete, issues.contains(where: { $0.actions.contains(where: { $0.id == "wait" }) }) else { return nil } + guard allowsInstallation, !isRunning, phase != .complete, issues.contains(where: { $0.actions.contains(where: { $0.id == "wait" }) }) else { return nil } let current = begin(.waiting, message: "Waiting for the installation requirements to clear…") let task = Task { [self] in do { @@ -546,9 +649,12 @@ final class QueueBootstrapModel: ObservableObject { } func canPerform(_ action: QueueBootstrapAction, issue: QueueBootstrapIssue) -> Bool { - !isRunning && (action.id != "reset-callback" || (reviewedIssueID == issue.id && revision != nil)) + !isRunning && (allowsInstallation || QueueBootstrapAction.navigation.contains(action.id)) && + (action.id != "reset-callback" || (reviewedIssueID == issue.id && revision != nil)) } + func showLoginItems() { SMAppService.openSystemSettingsLoginItems() } + func reveal(_ issue: QueueBootstrapIssue) { guard !isRunning, issues.contains(issue), let path = issue.path else { return } let url = URL(fileURLWithPath: path).standardizedFileURL @@ -680,8 +786,10 @@ struct QueueBootstrapView: View { @State private var pending: QueueBootstrapPendingAction? var body: some View { VStack(alignment: .leading, spacing: 16) { - Label("Set up Agent Queue", systemImage: QueueBrand.symbolName).font(.title2.bold()) - Text("Setup is automatic for your macOS account. Review any requirements below before continuing.") + Label(model.allowsInstallation ? "Set up Agent Queue" : "Agent Queue needs setup", systemImage: QueueBrand.symbolName).font(.title2.bold()) + Text(model.allowsInstallation + ? "Review installation requirements before continuing." + : "Use the installer to install or repair Agent Queue for your macOS account.") .font(.callout).foregroundStyle(.secondary) HStack { if model.isRunning { ProgressView().controlSize(.small) } @@ -742,7 +850,15 @@ struct QueueBootstrapView: View { Spacer() Button("Quit") { NSApp.terminate(nil) }.disabled(model.isRunning) if !model.isRunning && model.phase != .complete { - if model.canDisablePhoneAccess && model.issues.isEmpty { + if !model.allowsInstallation { + if model.requiresServiceApproval { + Button("Login Items settings") { model.showLoginItems() } + } else { + Button("Download installer") { model.downloadRelease() } + } + Button("Check again") { model.start() } + .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) + } else if model.canDisablePhoneAccess && model.issues.isEmpty { Button("Disable phone access and continue") { model.start(disablePhoneAccess: true) } .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) } else { diff --git a/native/AgentQueue.swift b/native/AgentQueue.swift index 7d64361..738b5a8 100644 --- a/native/AgentQueue.swift +++ b/native/AgentQueue.swift @@ -190,7 +190,7 @@ enum QueueClient { let discardable = input == nil && (arguments.first == "status" || (controller == .heavy && arguments.first == "profiles" && ["list", "show"].contains(arguments.dropFirst().first ?? "")) || - (controller == .heavy && isSetupPreflight(arguments))) + (controller == .heavy && (isSetupPreflight(arguments) || isNativeServiceStatus(arguments)))) let cancellation = discardable ? QueueReadCancellation() : nil return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in @@ -214,12 +214,20 @@ enum QueueClient { Array(arguments.suffix(2)) == ["--preflight", "--json"] } + private static func isNativeServiceStatus(_ arguments: [String]) -> Bool { + arguments.count == 3 && arguments[0] == "--service-management" && arguments[1] == "status" && + ["queue", "resources", "remote", "login"].contains(arguments[2]) + } + static func timeout(for arguments: [String], controller: Controller = .heavy, budget: Budget = .normal) -> TimeInterval { + if controller == .heavy && isNativeServiceStatus(arguments) { return 5 } // A verified update includes a bounded download and signature checks. The // backend has a 120-second aggregate deadline; normal queue I/O is unchanged. if budget == .updates && controller == .heavy && arguments.first == "updates" { return 130 } if budget == .setup { return 330 } - return arguments.first == "status" ? 8 : (controller == .remote ? 35 : 25) + // Phone lifecycle owns up to 120 seconds for native stop/start and + // readiness. Its caller must allow startup and result delivery too. + return arguments.first == "status" ? 8 : (controller == .remote ? 125 : 25) } private static func runBlocking(_ arguments: [String], controller: Controller, input: Data?, budget: Budget, @@ -236,8 +244,18 @@ enum QueueClient { throw ClientError.message("Could not prepare the private controller output pipe.") } let process = Process() - process.executableURL = transport?.executable ?? FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".local/bin/\(controller.rawValue)") + if let transport { + process.executableURL = transport.executable + } else if Bundle.main.object(forInfoDictionaryKey: "AgentQueueServiceManagement") as? Bool == true { + let app = Bundle.main.bundleURL + let executable = app.appendingPathComponent("Contents/MacOS/\(controller.rawValue)") + guard try QueueBootstrapFiles.safeExecutable(executable, scope: .init(root: app, bundled: true)) else { + throw ClientError.message("The bundled controller is missing or unsafe. Use a complete installer to repair Agent Queue.") + } + process.executableURL = executable + } else { + process.executableURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin/\(controller.rawValue)") + } process.arguments = arguments let inputPipe = input == nil ? nil : Pipe() if let inputPipe { @@ -2196,6 +2214,17 @@ struct ConfirmationVerification: Encodable { @main struct AgentQueueApp { @MainActor static func main() { + do { + let arguments = Array(CommandLine.arguments.dropFirst()) + if let result = try QueueAppReplacement.handle(arguments: arguments) + ?? QueueServiceManagement.handle(arguments: arguments) { + FileHandle.standardOutput.write(Data((result.json + "\n").utf8)) + exit(result.exitCode) + } + } catch { + FileHandle.standardError.write(Data((error.localizedDescription + "\n").utf8)) + exit(EXIT_FAILURE) + } let application = NSApplication.shared application.setActivationPolicy(.accessory) let delegate = AppDelegate() diff --git a/native/AgentServices.swift b/native/AgentServices.swift new file mode 100644 index 0000000..e8bdc03 --- /dev/null +++ b/native/AgentServices.swift @@ -0,0 +1,441 @@ +import AppKit +import CoreFoundation +import Darwin +import Foundation +import ServiceManagement + +/// Native CLI arguments exclude argv[0]. Call before starting NSApplication. +/// A nil result means this is an ordinary app launch. Print result.json to stdout +/// and exit with result.exitCode. On a thrown error, print to stderr and exit 1. +/// This entry point never opens System Settings or changes legacy launchd jobs. +enum QueueServiceManagement { + enum Action: String, CaseIterable, Sendable { + case status, register, unregister + } + + enum Service: String, Codable, CaseIterable, Sendable { + case queue, resources, remote, login + + var identity: Identity { + switch self { + case .queue: .agent(plistName: "io.went.agent-heavy.plist") + case .resources: .agent(plistName: "io.went.agent-resources.plist") + case .remote: .agent(plistName: "io.went.agent-remote.plist") + case .login: .mainApp + } + } + } + + enum Identity: Equatable, Sendable { + case agent(plistName: String) + case mainApp + } + + enum Status: String, Codable, CaseIterable, Sendable { + case notRegistered, enabled, requiresApproval, notFound + } + + struct Command: Equatable, Sendable { + let action: Action + let service: Service + } + + /// Tests supply all three operations. No test needs an SMAppService instance. + struct Adapter { + let status: () throws -> Status + let register: () throws -> Void + let unregister: () throws -> Void + } + + struct Record: Encodable, Equatable, Sendable { + let schemaVersion = 1 + let service: Service + let status: Status + } + + struct CLIResult: Equatable, Sendable { + let json: String + /// status queries succeed for every known state. Mutations succeed only + /// when their requested end state is observed. Approval pending is code 2. + let exitCode: Int32 + } + + enum Failure: Error, LocalizedError, Equatable { + case invalidArguments + case invalidOwningBundle + case unknownStatus(Int) + case unregisterTimedOut + + var errorDescription: String? { + switch self { + case .invalidArguments: + "Usage: --service-management status|register|unregister queue|resources|remote|login" + case .invalidOwningBundle: + "Service management requires the packaged io.went.agent-queue app with AgentQueueServiceManagement set to boolean true." + case .unknownStatus(let value): + "Service management returned an unsupported status: \(value)." + case .unregisterTimedOut: + "Service shutdown did not complete within 30 seconds. Keep the installed app in place and review its service status." + } + } + } + + static func parse(arguments: [String]) throws -> Command? { + guard arguments.contains(where: { $0.hasPrefix("--service-management") }) else { return nil } + guard arguments.count == 3, arguments[0] == "--service-management", + let action = Action(rawValue: arguments[1]), + let service = Service(rawValue: arguments[2]) else { + throw Failure.invalidArguments + } + return Command(action: action, service: service) + } + + /// Production entry point. The bundle gate runs before constructing any real + /// SMAppService, including for a read-only status request. + static func handle(arguments: [String]) throws -> CLIResult? { + try handle(arguments: arguments, owningBundle: .main, makeAdapter: systemAdapter) + } + + /// Injectable CLI boundary for private fixture bundles and inert adapters. + static func handle(arguments: [String], owningBundle: Bundle, + makeAdapter: (Identity) throws -> Adapter) throws -> CLIResult? { + guard let command = try parse(arguments: arguments) else { return nil } + try validateOwningBundle(owningBundle) + let record = try run(command, adapter: makeAdapter(command.service.identity)) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let json = String(decoding: try encoder.encode(record), as: UTF8.self) + let exitCode: Int32 + switch command.action { + case .status: exitCode = 0 + case .register: + exitCode = record.status == .enabled ? 0 : (record.status == .requiresApproval ? 2 : 1) + case .unregister: + exitCode = record.status == .notRegistered ? 0 : (record.status == .requiresApproval ? 2 : 1) + } + return CLIResult(json: json, exitCode: exitCode) + } + + /// Lifecycle policy shared by production and tests. Framework errors propagate + /// unchanged; a denied registration is never converted into a successful call. + static func run(_ command: Command, adapter: Adapter) throws -> Record { + let status: Status + switch command.action { + case .status: + status = try adapter.status() + case .register: + let current = try adapter.status() + switch current { + case .enabled, .requiresApproval, .notFound: + // A missing packaged service cannot be repaired by registration. + // Revoked consent requires the user's action in System Settings. + status = current + case .notRegistered: + try adapter.register() + status = try adapter.status() + } + case .unregister: + // Do not suppress errors, even kSMErrorJobNotFound. A successful API + // call is followed by inspection, not an assumed notRegistered state. + // The production adapter waits for Apple's completion callback before + // allowing the installer to replace code or re-register the service. + try adapter.unregister() + status = try adapter.status() + } + return Record(service: command.service, status: status) + } + + static func validateOwningBundle(_ bundle: Bundle) throws { + let root = bundle.bundleURL.resolvingSymlinksInPath().standardizedFileURL + guard root.pathExtension == "app", + bundle.bundleIdentifier == "io.went.agent-queue", + bundle.object(forInfoDictionaryKey: "CFBundlePackageType") as? String == "APPL", + let marker = bundle.object(forInfoDictionaryKey: "AgentQueueServiceManagement") as? NSNumber, + CFGetTypeID(marker) == CFBooleanGetTypeID(), marker.boolValue, + let name = bundle.object(forInfoDictionaryKey: "CFBundleExecutable") as? String, + !name.isEmpty, name != ".", name != "..", !name.contains("/"), + let executable = bundle.executableURL?.resolvingSymlinksInPath().standardizedFileURL, + executable == root.appendingPathComponent("Contents/MacOS").appendingPathComponent(name), + FileManager.default.isExecutableFile(atPath: executable.path), + QueueExecutableNamespace.allows(executable: bundle.executableURL ?? executable, root: bundle.bundleURL) else { + throw Failure.invalidOwningBundle + } + } + + private static func systemAdapter(_ identity: Identity) -> Adapter { + let service: SMAppService + switch identity { + case .agent(let plistName): service = SMAppService.agent(plistName: plistName) + case .mainApp: service = SMAppService.mainApp + } + return Adapter(status: { try mapStatus(service.status) }, + register: { try service.register() }, + unregister: { + try waitForUnregistration { completion in + service.unregister(completionHandler: completion) + } + }) + } + + // Apple's SDK documents this callback as the safe re-registration point. + // It runs on libdispatch's default queue, so the CLI can wait before AppKit + // starts without relying on the main run loop or a guessed sleep interval. + static func waitForUnregistration( + start: (@escaping @Sendable ((any Error)?) -> Void) -> Void + ) throws { + let completion = UnregistrationCompletion() + start { completion.finish($0) } + try completion.wait() + } + + private final class UnregistrationCompletion: @unchecked Sendable { + private let semaphore = DispatchSemaphore(value: 0) + private let lock = NSLock() + private var error: (any Error)? + + func finish(_ error: (any Error)?) { + lock.lock() + self.error = error + lock.unlock() + semaphore.signal() + } + + func wait() throws { + guard semaphore.wait(timeout: .now() + 30) == .success else { + throw Failure.unregisterTimedOut + } + lock.lock() + let failure = error + lock.unlock() + if let failure { throw failure } + } + } + + static func mapStatus(_ status: SMAppService.Status) throws -> Status { + switch status { + case .notRegistered: .notRegistered + case .enabled: .enabled + case .requiresApproval: .requiresApproval + case .notFound: .notFound + @unknown default: throw Failure.unknownStatus(status.rawValue) + } + } +} + +/// Executable names must not be replaceable by another account. The broader +/// bundle-data reader may inspect shared Applications; execution cannot inherit +/// that exception. Root-owned sticky temporary ancestors retain their normal +/// owner protection for downloaded and private fixture bundles. +enum QueueExecutableNamespace { + static func allows(executable: URL, root: URL) -> Bool { + guard executable.isFileURL, root.isFileURL else { return false } + let path = physicalSystemPath(executable.path) + let boundary = physicalSystemPath(root.path) + guard path.hasPrefix(boundary + "/"), !path.contains("\0"), + physicalSystemPath(executable.standardizedFileURL.path) == path, + physicalSystemPath(root.standardizedFileURL.path) == boundary else { return false } + let components = path.split(separator: "/").map(String.init) + guard let name = components.last else { return false } + var directory = open("/", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard directory >= 0 else { return false } + defer { close(directory) } + var current = "" + for component in components.dropLast() { + let next = openat(directory, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard next >= 0 else { return false } + close(directory) + directory = next + current += "/" + component + var info = stat() + guard fstat(directory, &info) == 0 else { return false } + let inside = current == boundary || current.hasPrefix(boundary + "/") + let sticky = !inside && info.st_uid == 0 && info.st_mode & S_ISVTX != 0 + guard info.st_uid == getuid() || info.st_uid == 0, + info.st_mode & 0o022 == 0 || sticky else { return false } + } + let file = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard file >= 0 else { return false } + defer { close(file) } + var info = stat() + return fstat(file, &info) == 0 && info.st_mode & S_IFMT == S_IFREG && + (info.st_uid == getuid() || info.st_uid == 0) && info.st_nlink == 1 && + info.st_mode & 0o7022 == 0 && faccessat(directory, name, X_OK, 0) == 0 + } + + private static func physicalSystemPath(_ path: String) -> String { + if path.hasPrefix("/var/") || path.hasPrefix("/tmp/") { return "/private" + path } + return path + } +} + +/// Explicit installer boundary, separate from login-item unregistration. Run on +/// the main thread before NSApplication startup so AppKit can refresh live state. +@MainActor +enum QueueAppReplacement { + struct Metadata: Equatable { + let processID: pid_t + let bundleURL: URL? + let bundleIdentifier: String? + let userID: uid_t? + } + + /// All lifecycle operations and time are injected in the inert test suite. + /// Keep the NSRunningApplication instance, rather than sending signals by PID. + struct RunningApp { + let metadata: () throws -> Metadata + let isTerminated: () throws -> Bool + let terminate: () throws -> Bool + } + + struct Adapter { + let applications: () throws -> [RunningApp] + let now: () -> TimeInterval + let advanceRunLoop: (TimeInterval) throws -> Void + } + + private struct Record: Encodable { + let schemaVersion = 1 + let app: String + let status = "stopped" + } + + enum Failure: Error, LocalizedError, Equatable { + case invalidArguments + case invalidTarget + case unverifiedOwnership + case processChanged + case terminationRefused + case terminationTimedOut + + var errorDescription: String? { + switch self { + case .invalidArguments: + "Usage: --prepare-app-replacement ABSOLUTE_APP_PATH" + case .invalidTarget: + "App replacement requires a canonical app directly in the current user's Applications directory or /Applications." + case .unverifiedOwnership: + "Cannot verify ownership of the running Agent Queue app. Keep the installed app in place." + case .processChanged: + "The running Agent Queue app changed during replacement preparation. Keep the installed app in place and retry." + case .terminationRefused: + "The running Agent Queue app refused the quit request. Keep the installed app in place." + case .terminationTimedOut: + "The running Agent Queue app did not exit within 30 seconds. Keep the installed app in place." + } + } + } + + static func parse(arguments: [String]) throws -> String? { + guard arguments.contains(where: { $0.hasPrefix("--prepare-app-replacement") }) else { return nil } + guard arguments.count == 2, arguments[0] == "--prepare-app-replacement", + arguments[1].hasPrefix("/") else { throw Failure.invalidArguments } + return arguments[1] + } + + static func handle(arguments: [String]) throws -> QueueServiceManagement.CLIResult? { + try handle(arguments: arguments, owningBundle: .main, + home: FileManager.default.homeDirectoryForCurrentUser, + currentUserID: getuid(), currentProcessID: getpid(), makeAdapter: systemAdapter) + } + + static func handle(arguments: [String], owningBundle: Bundle, home: URL, + currentUserID: uid_t, currentProcessID: pid_t, + makeAdapter: () throws -> Adapter) throws -> QueueServiceManagement.CLIResult? { + guard let path = try parse(arguments: arguments) else { return nil } + try QueueServiceManagement.validateOwningBundle(owningBundle) + let target = try validateTarget(path, home: home) + try stop(target: target, currentUserID: currentUserID, + currentProcessID: currentProcessID, adapter: makeAdapter()) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let json = String(decoding: try encoder.encode(Record(app: target.path)), as: UTF8.self) + return .init(json: json, exitCode: 0) + } + + static func validateTarget(_ path: String, home: URL) throws -> URL { + let target = URL(fileURLWithPath: path) + let userApplications = home.resolvingSymlinksInPath().standardizedFileURL + .appendingPathComponent("Applications") + guard path.hasPrefix("/"), target.path == path, + target.standardizedFileURL.path == path, + target.resolvingSymlinksInPath().standardizedFileURL.path == path, + target.lastPathComponent.hasSuffix(".app"), + target.deletingLastPathComponent().path == userApplications.path + || target.deletingLastPathComponent().path == "/Applications" else { + throw Failure.invalidTarget + } + return target + } + + private static func matches(_ metadata: Metadata, target: URL, + currentUserID: uid_t, currentProcessID: pid_t) throws -> Bool { + guard metadata.processID > 0, metadata.processID != currentProcessID, + metadata.bundleIdentifier == "io.went.agent-queue", + let url = metadata.bundleURL, url.isFileURL, + url.resolvingSymlinksInPath().standardizedFileURL == target else { return false } + guard let userID = metadata.userID else { throw Failure.unverifiedOwnership } + return userID == currentUserID + } + + private static func stop(target: URL, currentUserID: uid_t, currentProcessID: pid_t, + adapter: Adapter) throws { + var matching: [(RunningApp, Metadata)] = [] + for application in try adapter.applications() { + if try application.isTerminated() { continue } + let metadata = try application.metadata() + if try matches(metadata, target: target, currentUserID: currentUserID, + currentProcessID: currentProcessID) { + matching.append((application, metadata)) + } + } + let deadline = adapter.now() + 30 + for (application, metadata) in matching { + if try application.isTerminated() { continue } + guard try application.metadata() == metadata, + try matches(metadata, target: target, currentUserID: currentUserID, + currentProcessID: currentProcessID) else { throw Failure.processChanged } + guard try application.terminate() else { throw Failure.terminationRefused } + } + while true { + var pending = false + for (application, _) in matching where try !application.isTerminated() { pending = true } + if !pending { break } + let remaining = deadline - adapter.now() + guard remaining > 0 else { throw Failure.terminationTimedOut } + try adapter.advanceRunLoop(min(0.05, remaining)) + } + // A replacement must also fail if another target instance appeared while + // the original was quitting. Never chase or kill a newly launched copy. + for application in try adapter.applications() { + if try application.isTerminated() { continue } + if try matches(application.metadata(), target: target, currentUserID: currentUserID, + currentProcessID: currentProcessID) { + throw Failure.processChanged + } + } + } + + private static func systemAdapter() -> Adapter { + Adapter(applications: { + NSRunningApplication.runningApplications(withBundleIdentifier: "io.went.agent-queue").map { app in + RunningApp(metadata: { + Metadata(processID: app.processIdentifier, bundleURL: app.bundleURL, + bundleIdentifier: app.bundleIdentifier, userID: processOwner(app.processIdentifier)) + }, isTerminated: { app.isTerminated }, terminate: { app.terminate() }) + } + }, now: { ProcessInfo.processInfo.systemUptime }, advanceRunLoop: { interval in + // AppKit documents time-varying properties as cached until the next + // main run-loop turn. Sleeping or semaphore polling leaves them stale. + RunLoop.main.run(until: Date(timeIntervalSinceNow: interval)) + }) + } + + private static func processOwner(_ processID: pid_t) -> uid_t? { + guard processID > 0 else { return nil } + var info = proc_bsdinfo() + let size = Int32(MemoryLayout.size) + guard proc_pidinfo(processID, PROC_PIDTBSDINFO, 0, &info, size) == size, + info.pbi_pid == UInt32(processID), info.pbi_uid == info.pbi_ruid else { return nil } + return info.pbi_uid + } +} diff --git a/native/Installer.swift b/native/Installer.swift index 6cbf4c7..ad6a7ef 100644 --- a/native/Installer.swift +++ b/native/Installer.swift @@ -7,7 +7,7 @@ enum InstallerActivity { static var running = false } @MainActor final class InstallerModel: ObservableObject { - @Published var output = "Install Agent Queue for your macOS account.\n\nIncludes its queue and HTTPS runtime. macOS may need an internet connection for first-open verification. Finish or cancel all existing jobs before the first legacy queue migration. Later embedded queue upgrades preserve running jobs and settings. Manage optional phone access in settings.\n" + @Published var output = "Install Agent Queue for your macOS account.\n\nIncludes its queue and HTTPS runtime. macOS may need an internet connection for first-open verification. Finish or cancel all queued and running jobs before installation or an upgrade. Active work blocks changes to background services. Configuration, history and phone credentials are preserved. Manage optional phone access in settings.\n" @Published var running = false @Published var completed = false private var process: Process? @@ -21,6 +21,10 @@ final class InstallerModel: ObservableObject { } let payload = resources.appendingPathComponent("payload") let controller = payload.appendingPathComponent("source/bin/agent-heavy") + guard QueueExecutableNamespace.allows(executable: controller, root: Bundle.main.bundleURL) else { + output += "\nThe installer location permits unsafe executable replacement. Open it from a protected download or your account's Applications folder. No installation was started.\n" + return + } let task = Process() let stream = Pipe() task.executableURL = controller diff --git a/native/LaunchAgents/io.went.agent-heavy.plist b/native/LaunchAgents/io.went.agent-heavy.plist new file mode 100644 index 0000000..23e52dc --- /dev/null +++ b/native/LaunchAgents/io.went.agent-heavy.plist @@ -0,0 +1,12 @@ + + + +Labelio.went.agent-heavy +BundleProgramContents/MacOS/agent-heavy +ProgramArgumentsagent-heavydaemon +RunAtLoad +KeepAliveSuccessfulExit +ThrottleInterval10 +Umask63 +ProcessTypeBackground + diff --git a/native/LaunchAgents/io.went.agent-remote.plist b/native/LaunchAgents/io.went.agent-remote.plist new file mode 100644 index 0000000..25c774d --- /dev/null +++ b/native/LaunchAgents/io.went.agent-remote.plist @@ -0,0 +1,12 @@ + + + +Labelio.went.agent-remote +BundleProgramContents/MacOS/agent-remote +ProgramArgumentsagent-remoteserve +RunAtLoad +KeepAliveSuccessfulExit +ThrottleInterval10 +Umask63 +ProcessTypeBackground + diff --git a/native/LaunchAgents/io.went.agent-resources.plist b/native/LaunchAgents/io.went.agent-resources.plist new file mode 100644 index 0000000..6d2baf3 --- /dev/null +++ b/native/LaunchAgents/io.went.agent-resources.plist @@ -0,0 +1,11 @@ + + + +Labelio.went.agent-resources +BundleProgramContents/MacOS/agent-heavy +ProgramArgumentsagent-heavyscheduler +KeepAlive +ThrottleInterval10 +Umask63 +ProcessTypeBackground + diff --git a/native/build.sh b/native/build.sh index aae3f57..fd1d38f 100755 --- a/native/build.sh +++ b/native/build.sh @@ -11,8 +11,8 @@ binaries=() for architecture in $architectures; do case "$architecture" in arm64|x86_64) ;; *) echo "Unsupported architecture: $architecture" >&2; exit 1 ;; esac xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -O \ - -target "$architecture-apple-macos15.0" -framework SwiftUI -framework AppKit \ - "$source_dir/QueueBrand.swift" "$source_dir/AgentQueue.swift" "$source_dir/AgentIntegration.swift" "$source_dir/AgentUpdates.swift" "$source_dir/AgentLearning.swift" "$source_dir/AgentBootstrap.swift" -o "$build_dir/$architecture" + -target "$architecture-apple-macos15.0" -framework SwiftUI -framework AppKit -framework ServiceManagement \ + "$source_dir/QueueBrand.swift" "$source_dir/AgentQueue.swift" "$source_dir/AgentIntegration.swift" "$source_dir/AgentUpdates.swift" "$source_dir/AgentLearning.swift" "$source_dir/AgentBootstrap.swift" "$source_dir/AgentServices.swift" -o "$build_dir/$architecture" binaries+=("$build_dir/$architecture") done xcrun lipo -create "${binaries[@]}" -output "$output" diff --git a/native/tests/BootstrapFixtures.swift b/native/tests/BootstrapFixtures.swift index b2cda24..fd206db 100644 --- a/native/tests/BootstrapFixtures.swift +++ b/native/tests/BootstrapFixtures.swift @@ -8,22 +8,36 @@ struct BootstrapFixture: Sendable { let environment: QueueBootstrapEnvironment let identity: Data - init(parent: URL, location: String = "Applications", tracked: Bool = true) throws { + init(parent: URL, location: String = "Applications", tracked: Bool = true, + setupMode: Bool = true, managed: Bool = false) throws { directory = try Self.canonicalDirectory(parent).appendingPathComponent(UUID().uuidString) let home = directory.appendingPathComponent("home") let applications = directory.appendingPathComponent("Applications") let appParent = location == "home" ? home.appendingPathComponent("Applications") : directory.appendingPathComponent(location) let app = appParent.appendingPathComponent("Agent Queue.app") environment = QueueBootstrapEnvironment(app: app, resources: app.appendingPathComponent("Contents/Resources"), - home: home, systemApplications: applications) + home: home, arguments: setupMode ? ["--setup"] : [], systemApplications: applications) identity = try JSONEncoder().encode(BootstrapFixtureIdentity(version: "1.2.3", commit: tracked ? String(repeating: "a", count: 40) : nil, build: tracked ? 42 : nil)) - try Self.write(Data("#!/bin/sh\nexit 99\n".utf8), to: environment.source.appendingPathComponent("bin/agent-heavy"), executable: true) - try Self.write(Data("#!/bin/sh\nexit 99\n".utf8), to: environment.source.appendingPathComponent("bin/agent-remote"), executable: true) + let binaries = managed ? app.appendingPathComponent("Contents/MacOS") : environment.source.appendingPathComponent("bin") + try Self.write(Data("#!/bin/sh\nexit 99\n".utf8), to: binaries.appendingPathComponent("agent-heavy"), executable: true) + try Self.write(Data("#!/bin/sh\nexit 99\n".utf8), to: binaries.appendingPathComponent("agent-remote"), executable: true) + if managed { + try Self.write(Data("#!/bin/sh\nexit 99\n".utf8), to: binaries.appendingPathComponent("AgentQueue"), executable: true) + for label in ["io.went.agent-heavy", "io.went.agent-resources", "io.went.agent-remote"] { + let remote = label == "io.went.agent-remote" + let command = remote ? "agent-remote" : "agent-heavy" + let action = remote ? "serve" : (label == "io.went.agent-heavy" ? "daemon" : "scheduler") + let definition: [String: Any] = ["Label": label, "BundleProgram": "Contents/MacOS/\(command)", + "ProgramArguments": [command, action]] + try Self.write(try PropertyListSerialization.data(fromPropertyList: definition, format: .xml, options: 0), + to: app.appendingPathComponent("Contents/Library/LaunchAgents/\(label).plist")) + } + } try Self.write(identity, to: environment.source.appendingPathComponent("build-info.json")) try Self.write(identity, to: environment.resources.appendingPathComponent("build-info.json")) let info: [String: Any] = ["CFBundleIdentifier": "io.went.agent-queue", "CFBundleShortVersionString": "1.2.3", - "CFBundleVersion": tracked ? "42" : "0", "AgentQueueEmbeddedPayload": true] + "CFBundleVersion": tracked ? "42" : "0", "AgentQueueEmbeddedPayload": true, "AgentQueueServiceManagement": managed] try Self.write(try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0), to: app.appendingPathComponent("Contents/Info.plist")) } @@ -43,6 +57,16 @@ struct BootstrapFixture: Sendable { if FileManager.default.fileExists(atPath: pending.path) { try FileManager.default.removeItem(at: pending) } } + func installManaged() throws { + try Self.write(identity, to: environment.root.appendingPathComponent("build-info.json")) + let record = BootstrapFixtureInstallation(app: environment.app.path) + try Self.write(try JSONEncoder().encode(record), to: environment.root.appendingPathComponent("installation.json")) + for name in ["agent-heavy", "agent-remote"] { + try Self.write(Data("#!/bin/sh\nexit 99\n".utf8), + to: environment.home.appendingPathComponent(".local/bin/\(name)"), executable: true) + } + } + func writeDefinition(_ label: String, arguments: [String], program: String? = nil, runAtLoad: Bool? = true) throws { var definition: [String: Any] = ["Label": label, "ProgramArguments": arguments] definition["Program"] = program @@ -67,6 +91,34 @@ struct BootstrapFixture: Sendable { } } +private struct BootstrapFixtureInstallation: Encodable { + let schemaVersion = 1 + let app: String + let serviceManagement = true +} + +actor BootstrapServiceStatusFixture { + private(set) var requests: [QueueBootstrapRequest] = [] + let status: String + + init(status: String = "enabled") { self.status = status } + + func execute(_ request: QueueBootstrapRequest) throws -> Data { + requests.append(request) + guard request.arguments.count == 3, request.arguments.prefix(2) == ["--service-management", "status"], + let service = request.arguments.last else { + throw ClientError.message("Ordinary startup attempted a service mutation.") + } + return try JSONEncoder().encode(BootstrapFixtureServiceStatus(service: service, status: status)) + } +} + +private struct BootstrapFixtureServiceStatus: Encodable { + let schemaVersion = 1 + let service: String + let status: String +} + struct BootstrapFixtureIdentity: Encodable { let schemaVersion = 1 let repository = "Dunky13/agent-job-queue" diff --git a/native/tests/BootstrapTests.swift b/native/tests/BootstrapTests.swift index ce648a3..b5b2a36 100644 --- a/native/tests/BootstrapTests.swift +++ b/native/tests/BootstrapTests.swift @@ -24,12 +24,108 @@ struct BootstrapTests { catch { return error.localizedDescription.contains(text) } } + // Ordinary launch must never use the installation/recovery executor, + // including repeated launches after security software removed helpers. + for incomplete in [false, true] { + let ordinary = try BootstrapFixture(parent: temporary, setupMode: false) + if incomplete { + try BootstrapFixture.write(ordinary.identity, + to: ordinary.environment.root.appendingPathComponent("install-in-progress.json")) + } + let counter = BootstrapCountingExecutorFixture() + var completed = false + let startup = QueueBootstrapModel(environment: ordinary.environment, execute: { request, output in + await counter.execute(request, output: output) + }) { completed = true } + await startup.start()?.value + await startup.start()?.value + let calls = await counter.calls + try check(startup.phase == .setupRequired && !completed && calls == 0, + "Ordinary \(incomplete ? "incomplete" : "fresh") startup directs the user to the installer without executing setup") + try check(startup.start(disablePhoneAccess: true) == nil && startup.waitAndContinue() == nil, + "Ordinary startup cannot enter mutation or recovery through hidden actions") + try check(!FileManager.default.fileExists(atPath: ordinary.environment.root.appendingPathComponent("bin").path) && + !FileManager.default.fileExists(atPath: ordinary.environment.home.appendingPathComponent("Library/LaunchAgents").path), + "Ordinary startup creates neither private executables nor launch definitions") + if incomplete { + try check(FileManager.default.fileExists(atPath: ordinary.environment.root.appendingPathComponent("install-in-progress.json").path), + "Ordinary startup preserves the incomplete-install marker") + } + } + + let stalled = try BootstrapFixture(parent: temporary, setupMode: false, managed: true) + try stalled.installManaged() + let stalledPID = temporary.appendingPathComponent("native-startup-read.pid") + let quotedPID = "'" + stalledPID.path.replacingOccurrences(of: "'", with: "'\\''") + "'" + let stalledLauncher = stalled.environment.app.appendingPathComponent("Contents/MacOS/AgentQueue") + try BootstrapFixture.write(Data("#!/bin/sh\nprintf '%s' \"$$\" > \(quotedPID)\nexec /bin/sleep 30\n".utf8), + to: stalledLauncher, executable: true) + var stalledCompleted = false + let stalledModel = QueueBootstrapModel(environment: stalled.environment) { stalledCompleted = true } + let stalledTask = stalledModel.start() + let readDeadline = ProcessInfo.processInfo.systemUptime + 3 + while !FileManager.default.fileExists(atPath: stalledPID.path) { + guard ProcessInfo.processInfo.systemUptime < readDeadline else { + stalledTask?.cancel() + await stalledTask?.value + throw ClientError.message("Private native status read did not launch") + } + try await Task.sleep(for: .milliseconds(5)) + } + let stoppedAt = ProcessInfo.processInfo.systemUptime + stalledModel.cancelWaiting() + await stalledTask?.value + let childPID = Int32(try String(contentsOf: stalledPID, encoding: .utf8)) ?? -1 + try check(childPID > 1 && kill(childPID, 0) == -1 && errno == ESRCH && + ProcessInfo.processInfo.systemUptime - stoppedAt < 1 && !stalledCompleted && !stalledModel.isRunning, + "Stopping ordinary startup cancels and reaps the actual private native status child") + + for status in ["enabled", "requiresApproval", "notRegistered", "notFound", "invalid"] { + let managed = try BootstrapFixture(parent: temporary, setupMode: false, managed: true) + try managed.installManaged() + let readback = BootstrapServiceStatusFixture(status: status) + var completed = false + let startup = QueueBootstrapModel(environment: managed.environment, execute: { request, _ in + try await readback.execute(request) + }) { completed = true } + await startup.start()?.value + let requests = await readback.requests + try check(!requests.isEmpty && requests.allSatisfy { $0.arguments.prefix(2) == ["--service-management", "status"] }, + "Managed startup reads service status only for \(status)") + if status == "enabled" { + try check(startup.phase == .complete && completed && requests.count == 2, + "Enabled managed services open the queue without registration") + } else if status == "invalid" { + try check(startup.phase == .failed && !completed, + "Malformed managed service status cannot authorize readiness") + } else { + try check(startup.phase == .setupRequired && !completed && + startup.requiresServiceApproval == (status == "requiresApproval"), + "Managed \(status) remains explicit and cannot trigger re-registration") + } + } + + let tamperedManaged = try BootstrapFixture(parent: temporary, setupMode: false, managed: true) + try tamperedManaged.installManaged() + let definition = tamperedManaged.environment.app.appendingPathComponent("Contents/Library/LaunchAgents/io.went.agent-heavy.plist") + let outsideProgram: [String: Any] = ["Label": "io.went.agent-heavy", "BundleProgram": "Contents/MacOS/agent-heavy", + "ProgramArguments": ["agent-heavy", "daemon"], "Program": "/usr/bin/false"] + try BootstrapFixture.write(try PropertyListSerialization.data(fromPropertyList: outsideProgram, format: .xml, options: 0), to: definition) + let tamperedCounter = BootstrapCountingExecutorFixture() + let tamperedStartup = QueueBootstrapModel(environment: tamperedManaged.environment, execute: { request, output in + await tamperedCounter.execute(request, output: output) + }) { } + await tamperedStartup.start()?.value + let tamperedCalls = await tamperedCounter.calls + try check(tamperedStartup.phase == .failed && tamperedCalls == 0, + "A bundled service with an external Program cannot authorize startup or run a controller") + let fresh = try fixture() let decision = try fresh.environment.inspect() if case let .install(request, _) = decision { try check(request.executable == fresh.environment.source.appendingPathComponent("bin/agent-heavy") && request.arguments == ["install", "--app-bundle", fresh.environment.app.path], - "Fresh launch runs only its own bundled installer targeting the running app") + "Explicit setup selects its own bundled installer targeting the supplied app") } else { throw ClientError.message("Fresh package did not require setup") } try check(!FileManager.default.fileExists(atPath: fresh.environment.root.path), "Inspection makes no backend changes") try fresh.install() @@ -167,7 +263,7 @@ struct BootstrapTests { try fresh.install() let marker = fresh.environment.root.appendingPathComponent("install-in-progress.json") try BootstrapFixture.write(Data("unfinished".utf8), to: marker) - try check(try needsSetup(fresh.environment), "Any incomplete-install marker forces safe backend retry") + try check(try needsSetup(fresh.environment), "An incomplete-install marker requires an explicit installation step") try FileManager.default.removeItem(at: marker) for name in ["agent-heavy", "agent-remote"] { for path in [fresh.environment.root.appendingPathComponent("bin/\(name)"), fresh.environment.home.appendingPathComponent(".local/bin/\(name)")] { diff --git a/native/tests/BootstrapUITests.swift b/native/tests/BootstrapUITests.swift index 4268833..533b505 100644 --- a/native/tests/BootstrapUITests.swift +++ b/native/tests/BootstrapUITests.swift @@ -69,6 +69,25 @@ final class BootstrapUIFixtureDelegate: NSObject, NSApplicationDelegate { try await verifyPreflight(notification) return } + let ordinary = try BootstrapFixture(parent: temporary, setupMode: false) + let counter = BootstrapCountingExecutorFixture() + let ordinaryDelegate = AppDelegate(environment: ordinary.environment) { request, output in + await counter.execute(request, output: output) + } + production = ordinaryDelegate + ordinaryDelegate.applicationDidFinishLaunching(notification) + try await waitUntil { ordinaryDelegate.bootstrap?.phase == .setupRequired } + guard let requiredWindow = ordinaryDelegate.setupWindow, let requiredModel = ordinaryDelegate.bootstrap else { + throw ClientError.message("Read-only startup did not show installation guidance") + } + await requiredModel.start()?.value + let ordinaryCalls = await counter.calls + try check(requiredWindow.isVisible && ordinaryCalls == 0 && !requiredModel.allowsInstallation && + ordinaryDelegate.applicationShouldTerminate(NSApp) == .terminateNow, + "Normal startup shows installer guidance without executing setup and permits Quit") + try snapshot(requiredWindow, name: "00-installer-required") + requiredWindow.close() + ordinaryDelegate.applicationWillTerminate(notification) let fixture = try BootstrapFixture(parent: temporary) let phoneAccessBlocker = !CommandLine.arguments.contains("--generic-retry") let executor = BootstrapExecutorFixture(fixture, phoneAccessBlocker: phoneAccessBlocker) diff --git a/native/tests/ServiceManagementTests.swift b/native/tests/ServiceManagementTests.swift new file mode 100644 index 0000000..ea5748f --- /dev/null +++ b/native/tests/ServiceManagementTests.swift @@ -0,0 +1,626 @@ +import Foundation +import ServiceManagement + +private typealias Management = QueueServiceManagement +private typealias Replacement = QueueAppReplacement + +private struct TestFailure: Error { + let message: String +} + +private final class Fixture { + var status: Management.Status + var afterRegister: Management.Status = .enabled + var afterUnregister: Management.Status = .notRegistered + var statusError: (any Error)? + var registerError: (any Error)? + var unregisterError: (any Error)? + var events: [String] = [] + + init(_ status: Management.Status) { self.status = status } + + var adapter: Management.Adapter { + Management.Adapter(status: { + self.events.append("status") + if let error = self.statusError { throw error } + return self.status + }, register: { + self.events.append("register") + if let error = self.registerError { throw error } + self.status = self.afterRegister + }, unregister: { + self.events.append("unregister") + if let error = self.unregisterError { throw error } + self.status = self.afterUnregister + }) + } +} + +@MainActor +private final class AppFixture { + var metadata: Replacement.Metadata + var terminated = false + var acceptsTermination = true + var terminationError: (any Error)? + var observationError: (any Error)? + var metadataError: (any Error)? + var onMetadata: (() -> Void)? + var terminationRequests = 0 + var finishImmediately = true + + init(path: URL?, identifier: String? = "io.went.agent-queue", pid: Int32 = 42, uid: UInt32? = 501) { + metadata = .init(processID: pid, bundleURL: path, bundleIdentifier: identifier, userID: uid) + } + + var application: Replacement.RunningApp { + .init(metadata: { + self.onMetadata?() + if let error = self.metadataError { throw error } + return self.metadata + }, isTerminated: { + if let error = self.observationError { throw error } + return self.terminated + }, terminate: { + self.terminationRequests += 1 + if let error = self.terminationError { throw error } + if self.acceptsTermination && self.finishImmediately { self.terminated = true } + return self.acceptsTermination + }) + } +} + +@MainActor +private final class ReplacementFixture { + var apps: [AppFixture] = [] + var time: TimeInterval = 0 + var advances: [TimeInterval] = [] + var enumerationCount = 0 + var enumerationError: (any Error)? + var advanceError: (any Error)? + var onAdvance: (() -> Void)? + var onEnumeration: (() -> Void)? + + var adapter: Replacement.Adapter { + .init(applications: { + self.enumerationCount += 1 + self.onEnumeration?() + if let error = self.enumerationError { throw error } + return self.apps.map(\.application) + }, now: { self.time }, advanceRunLoop: { interval in + self.advances.append(interval) + if let error = self.advanceError { throw error } + self.time += interval + self.onAdvance?() + }) + } +} + +@main +struct ServiceManagementTests { + @MainActor static func main() throws { + let temporary = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temporary) } + var passed = 0 + + func check(_ condition: Bool, _ name: String) throws { + guard condition else { throw TestFailure(message: name) } + passed += 1 + print("PASS \(name)") + } + + func failure(_ expected: Management.Failure, _ name: String, + operation: () throws -> Void) throws { + do { + try operation() + throw TestFailure(message: "\(name): expected failure") + } catch let error as Management.Failure { + try check(error == expected, name) + } + } + + func propagated(_ expected: NSError, _ name: String, operation: () throws -> Void) throws { + do { + try operation() + throw TestFailure(message: "\(name): expected framework error") + } catch let error as NSError where error.domain == expected.domain && error.code == expected.code { + try check(error === expected, name) + } + } + + func bundle(overrides: [String: Any] = [:], omitted: [String] = [], + extension suffix: String = "app", executable: Bool = true) throws -> Bundle { + let url = temporary.appendingPathComponent(UUID().uuidString).appendingPathExtension(suffix) + let macOS = url.appendingPathComponent("Contents/MacOS") + try FileManager.default.createDirectory(at: macOS, withIntermediateDirectories: true) + var info: [String: Any] = [ + "CFBundleIdentifier": "io.went.agent-queue", + "CFBundleExecutable": "Fixture", + "CFBundlePackageType": "APPL", + "CFBundleVersion": "1", + "AgentQueueServiceManagement": true + ] + info.merge(overrides) { _, replacement in replacement } + for key in omitted { info.removeValue(forKey: key) } + let plist = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + try plist.write(to: url.appendingPathComponent("Contents/Info.plist")) + if executable { + let executableURL = macOS.appendingPathComponent("Fixture") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: executableURL) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executableURL.path) + } + guard let result = Bundle(url: url) else { throw TestFailure(message: "Cannot load fixture bundle") } + return result + } + + for action in Management.Action.allCases { + for service in Management.Service.allCases { + let arguments = ["--service-management", action.rawValue, service.rawValue] + try check(try Management.parse(arguments: arguments) == .init(action: action, service: service), + "parse \(action.rawValue) \(service.rawValue)") + } + } + for arguments in [[], ["--fixture", "example.json"]] { + try check(try Management.parse(arguments: arguments) == nil, "ordinary app arguments are unhandled") + } + let invalidArguments = [ + ["--service-management"], ["--service-management", "status"], + ["--service-management", "enable", "queue"], ["--service-management", "status", "daemon"], + ["--service-management", "STATUS", "queue"], ["--service-management", "status", "Queue"], + ["--service-management", "register", "queue", "extra"], + ["extra", "--service-management", "register", "queue"], + ["--service-management=status", "queue"], + ["--service-management", "register", "../io.went.agent-heavy.plist"] + ] + for arguments in invalidArguments { + try failure(.invalidArguments, "reject malformed CLI: \(arguments.joined(separator: " "))") { + _ = try Management.parse(arguments: arguments) + } + } + + let owner = try bundle() + try Management.validateOwningBundle(owner) + guard let ownedExecutable = owner.executableURL else { + throw TestFailure(message: "Fixture executable is missing") + } + try check(QueueExecutableNamespace.allows(executable: ownedExecutable, root: owner.bundleURL), + "protected executable namespace is accepted") + let mutableParent = temporary.appendingPathComponent("Shared Applications") + try FileManager.default.createDirectory(at: mutableParent, withIntermediateDirectories: false) + let mutableURL = mutableParent.appendingPathComponent(owner.bundleURL.lastPathComponent) + try FileManager.default.copyItem(at: owner.bundleURL, to: mutableURL) + guard let mutableBundle = Bundle(url: mutableURL), let mutableExecutable = mutableBundle.executableURL else { + throw TestFailure(message: "Mutable namespace fixture is missing") + } + for mode in [0o770, 0o777] { + try FileManager.default.setAttributes([.posixPermissions: mode], ofItemAtPath: mutableParent.path) + try check(!QueueExecutableNamespace.allows(executable: mutableExecutable, root: mutableURL), + "foreign-writable executable ancestor is refused: \(mode)") + try failure(.invalidOwningBundle, "unsafe namespace is refused before framework access: \(mode)") { + _ = try Management.handle(arguments: ["--service-management", "status", "queue"], + owningBundle: mutableBundle) { _ in + throw TestFailure(message: "Unsafe namespace constructed a native adapter") + } + } + } + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: mutableParent.path) + try check(QueueExecutableNamespace.allows(executable: mutableExecutable, root: mutableURL), + "protected shared ancestor is accepted") + let alias = owner.bundleURL.appendingPathComponent("Contents/MacOS/Alias") + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: ownedExecutable) + try check(!QueueExecutableNamespace.allows(executable: alias, root: owner.bundleURL), + "executable symlink is refused") + try FileManager.default.removeItem(at: alias) + try FileManager.default.linkItem(at: ownedExecutable, to: alias) + try check(!QueueExecutableNamespace.allows(executable: ownedExecutable, root: owner.bundleURL), + "multiply linked executable is refused") + try FileManager.default.removeItem(at: alias) + let expectedIdentities: [(Management.Service, Management.Identity)] = [ + (.queue, .agent(plistName: "io.went.agent-heavy.plist")), + (.resources, .agent(plistName: "io.went.agent-resources.plist")), + (.remote, .agent(plistName: "io.went.agent-remote.plist")), + (.login, .mainApp) + ] + for (service, identity) in expectedIdentities { + var received: [Management.Identity] = [] + let fixture = Fixture(.enabled) + let result = try Management.handle(arguments: ["--service-management", "status", service.rawValue], + owningBundle: owner) { + received.append($0) + return fixture.adapter + } + try check(received == [identity], "\(service.rawValue) selects exact Apple service identity") + let expected = "{\"schemaVersion\":1,\"service\":\"\(service.rawValue)\",\"status\":\"enabled\"}" + try check(result == .init(json: expected, exitCode: 0), "\(service.rawValue) deterministic JSON") + } + + let appleStatuses: [(SMAppService.Status, Management.Status)] = [ + (.notRegistered, .notRegistered), (.enabled, .enabled), + (.requiresApproval, .requiresApproval), (.notFound, .notFound) + ] + for (apple, expected) in appleStatuses { + try check(try Management.mapStatus(apple) == expected, "Apple status maps \(expected.rawValue)") + let fixture = Fixture(expected) + let result = try Management.handle(arguments: ["--service-management", "status", "queue"], + owningBundle: owner, makeAdapter: { _ in fixture.adapter }) + try check(result == .init(json: "{\"schemaVersion\":1,\"service\":\"queue\",\"status\":\"\(expected.rawValue)\"}", + exitCode: 0) && fixture.events == ["status"], + "status reports \(expected.rawValue) without mutation") + } + if let unknown = SMAppService.Status(rawValue: 999) { + try failure(.unknownStatus(999), "unknown future Apple state fails closed") { + _ = try Management.mapStatus(unknown) + } + } + + for current in [Management.Status.enabled, .requiresApproval, .notFound] { + let fixture = Fixture(current) + let result = try Management.handle(arguments: ["--service-management", "register", "login"], + owningBundle: owner, makeAdapter: { _ in fixture.adapter }) + let expectedCode: Int32 = current == .enabled ? 0 : (current == .requiresApproval ? 2 : 1) + try check(result?.exitCode == expectedCode && fixture.events == ["status"], + "register preserves \(current.rawValue) without retrying or overriding consent") + } + for end in Management.Status.allCases { + let fixture = Fixture(.notRegistered) + fixture.afterRegister = end + let result = try Management.handle(arguments: ["--service-management", "register", "queue"], + owningBundle: owner, makeAdapter: { _ in fixture.adapter }) + let expectedCode: Int32 = end == .enabled ? 0 : (end == .requiresApproval ? 2 : 1) + try check(result?.exitCode == expectedCode && result?.json.contains("\"status\":\"\(end.rawValue)\"") == true + && fixture.events == ["status", "register", "status"], + "register inspects actual end state \(end.rawValue)") + } + for end in Management.Status.allCases { + let fixture = Fixture(.enabled) + fixture.afterUnregister = end + let result = try Management.handle(arguments: ["--service-management", "unregister", "remote"], + owningBundle: owner, makeAdapter: { _ in fixture.adapter }) + let expectedCode: Int32 = end == .notRegistered ? 0 : (end == .requiresApproval ? 2 : 1) + try check(result?.exitCode == expectedCode && fixture.events == ["unregister", "status"], + "unregister inspects actual end state \(end.rawValue)") + } + + let denied = NSError(domain: SMAppServiceErrorDomain, code: Int(kSMErrorLaunchDeniedByUser)) + let deniedFixture = Fixture(.notRegistered) + deniedFixture.registerError = denied + try propagated(denied, "denied registration error propagates unchanged") { + _ = try Management.run(.init(action: .register, service: .queue), adapter: deniedFixture.adapter) + } + try check(deniedFixture.events == ["status", "register"], "denied registration is not retried") + deniedFixture.status = .requiresApproval + deniedFixture.events = [] + let revoked = try Management.run(.init(action: .register, service: .queue), adapter: deniedFixture.adapter) + try check(revoked.status == .requiresApproval && deniedFixture.events == ["status"], + "revoked approval is reported without calling denied register again") + + let unavailable = NSError(domain: SMAppServiceErrorDomain, code: Int(kSMErrorServiceUnavailable)) + let readFixture = Fixture(.notRegistered) + readFixture.statusError = unavailable + try propagated(unavailable, "pre-registration status error prevents mutation") { + _ = try Management.run(.init(action: .register, service: .queue), adapter: readFixture.adapter) + } + try check(readFixture.events == ["status"], "failed status cannot authorize registration") + for code in [kSMErrorJobNotFound, kSMErrorAuthorizationFailure, kSMErrorServiceUnavailable] { + let error = NSError(domain: SMAppServiceErrorDomain, code: Int(code)) + let fixture = Fixture(.enabled) + fixture.unregisterError = error + try propagated(error, "unregister framework error \(code) propagates unchanged") { + _ = try Management.run(.init(action: .unregister, service: .queue), adapter: fixture.adapter) + } + try check(fixture.events == ["unregister"], "failed unregister does not fabricate a status") + } + let postRead = Fixture(.enabled) + postRead.statusError = unavailable + try propagated(unavailable, "post-unregister status errors propagate") { + _ = try Management.run(.init(action: .unregister, service: .queue), adapter: postRead.adapter) + } + try check(postRead.events == ["unregister", "status"], "unregister completion requires readable status") + try propagated(unavailable, "adapter creation errors propagate") { + _ = try Management.handle(arguments: ["--service-management", "status", "queue"], + owningBundle: owner, makeAdapter: { _ in throw unavailable }) + } + + let rejectedBundles = try [ + bundle(overrides: ["CFBundleIdentifier": "io.went.agent-queue.tests"]), + bundle(omitted: ["AgentQueueServiceManagement"]), + bundle(overrides: ["AgentQueueServiceManagement": false]), + bundle(overrides: ["AgentQueueServiceManagement": "true"]), + bundle(overrides: ["AgentQueueServiceManagement": 1]), + bundle(overrides: ["CFBundlePackageType": "BNDL"]), + bundle(overrides: ["CFBundleExecutable": "../Fixture"]), + bundle(extension: "bundle"), bundle(executable: false) + ] + for (index, rejected) in rejectedBundles.enumerated() { + for action in Management.Action.allCases { + var constructed = false + try failure(.invalidOwningBundle, "bundle gate \(index) rejects \(action.rawValue)") { + _ = try Management.handle(arguments: ["--service-management", action.rawValue, "queue"], + owningBundle: rejected) { _ in + constructed = true + return Fixture(.notRegistered).adapter + } + } + try check(!constructed, "bundle gate \(index) precedes adapter construction") + } + } + let escapedBundle = try bundle() + guard let escapedExecutable = escapedBundle.executableURL else { + throw TestFailure(message: "Fixture executable URL missing") + } + let external = temporary.appendingPathComponent("ExternalFixture") + try FileManager.default.moveItem(at: escapedExecutable, to: external) + try FileManager.default.createSymbolicLink(at: escapedExecutable, withDestinationURL: external) + try failure(.invalidOwningBundle, "executable symlink escaping owning bundle is rejected") { + try Management.validateOwningBundle(escapedBundle) + } + // Only the read-only production route is exercised. All mutations above + // use closures backed by Fixture, never Apple's real mutation methods. + try failure(.invalidOwningBundle, "standalone test executable cannot use production status") { + _ = try Management.handle(arguments: ["--service-management", "status", "queue"]) + } + for arguments in invalidArguments { + var constructed = false + try failure(.invalidArguments, "invalid CLI fails before adapter creation") { + _ = try Management.handle(arguments: arguments, owningBundle: owner) { _ in + constructed = true + return Fixture(.notRegistered).adapter + } + } + try check(!constructed, "invalid CLI performs no service operation") + } + let ordinary = try Management.handle(arguments: [], owningBundle: rejectedBundles[0]) { _ in + throw TestFailure(message: "Ordinary launch constructed a service") + } + try check(ordinary == nil, "ordinary app launch bypasses service mode") + try Management.waitForUnregistration { completion in + DispatchQueue.global().async { completion(nil) } + } + try check(true, "unregistration waits for an asynchronous successful completion") + let shutdownError = NSError(domain: "ServiceManagementTests.shutdown", code: 7) + try propagated(shutdownError, "asynchronous unregistration failure is preserved") { + try Management.waitForUnregistration { completion in + DispatchQueue.global().async { completion(shutdownError) } + } + } + + func replacementFailure(_ expected: Replacement.Failure, _ name: String, + operation: () throws -> Void) throws { + do { + try operation() + throw TestFailure(message: "\(name): expected replacement failure") + } catch let error as Replacement.Failure { + try check(error == expected, name) + } + } + + let home = temporary.resolvingSymlinksInPath().appendingPathComponent("User") + let target = home.appendingPathComponent("Applications/Agent Queue.app") + let replacementArguments = ["--prepare-app-replacement", target.path] + func prepare(_ fixture: ReplacementFixture) throws -> Management.CLIResult? { + try Replacement.handle(arguments: replacementArguments, owningBundle: owner, + home: home, currentUserID: 501, currentProcessID: 99, + makeAdapter: { fixture.adapter }) + } + try check(try Replacement.parse(arguments: replacementArguments) == target.path, + "replacement parses exact command and absolute target") + for arguments in [[], ["--setup"], ["--fixture", "private.json"], + ["--service-management", "status", "login"]] { + try check(try Replacement.parse(arguments: arguments) == nil, + "replacement leaves ordinary and existing service arguments unhandled") + let result = try Replacement.handle(arguments: arguments, owningBundle: rejectedBundles[0], + home: home, currentUserID: 501, currentProcessID: 99) { + throw TestFailure(message: "Ordinary launch constructed replacement adapter") + } + try check(result == nil, "ordinary launch bypasses replacement owner and adapter") + } + let malformedReplacementArguments = [ + ["--prepare-app-replacement"], ["--prepare-app-replacement", ""], + ["--prepare-app-replacement", "relative/Agent Queue.app"], + ["--prepare-app-replacement", "~/Applications/Agent Queue.app"], + ["--prepare-app-replacement", target.path, "extra"], + ["--prepare-app-replacement", target.path, "--prepare-app-replacement", target.path], + ["extra", "--prepare-app-replacement", target.path], + ["--prepare-app-replacement=\(target.path)"], + ["--prepare-app-replacement-extra", target.path], + ["--service-management", "status", "login", "--prepare-app-replacement", target.path], + ["--prepare-app-replacement", "--prepare-app-replacement"], + ["--prepare-app-replacement", target.path, "--setup"] + ] + for arguments in malformedReplacementArguments { + try replacementFailure(.invalidArguments, "malformed replacement arguments fail before adapters") { + _ = try Replacement.handle(arguments: arguments, owningBundle: owner, + home: home, currentUserID: 501, currentProcessID: 99) { + throw TestFailure(message: "Malformed replacement constructed adapter") + } + } + } + for rejected in rejectedBundles + [escapedBundle] { + try failure(.invalidOwningBundle, "replacement uses existing strict owning bundle gate") { + _ = try Replacement.handle(arguments: replacementArguments, owningBundle: rejected, + home: home, currentUserID: 501, currentProcessID: 99) { + throw TestFailure(message: "Rejected owner constructed replacement adapter") + } + } + } + try failure(.invalidOwningBundle, "standalone production replacement cannot enumerate host apps") { + _ = try Replacement.handle(arguments: replacementArguments) + } + for path in [target.path, "/Applications/Agent Queue.app"] { + try check(try Replacement.validateTarget(path, home: home).path == path, + "replacement accepts canonical user and system install locations") + } + for path in [temporary.appendingPathComponent("Other/Agent Queue.app").path, + home.appendingPathComponent("Applications/Other").path, + home.appendingPathComponent("Applications/Nested/Other.app").path, + target.path + "/", target.path + "/.", target.path + "/../Agent Queue.app", + target.path.replacingOccurrences(of: "/Applications/", with: "//Applications/"), + "/Applications/Nested/Agent Queue.app"] { + try replacementFailure(.invalidTarget, "noncanonical or different install target is rejected: \(path)") { + _ = try Replacement.handle(arguments: ["--prepare-app-replacement", path], owningBundle: owner, + home: home, currentUserID: 501, currentProcessID: 99) { + throw TestFailure(message: "Invalid target constructed adapter") + } + } + } + let empty = ReplacementFixture() + let stoppedJSON = "{\"app\":\"\(target.path)\",\"schemaVersion\":1,\"status\":\"stopped\"}" + try check(!FileManager.default.fileExists(atPath: target.path), "no-running fixture has no target files") + try check(try prepare(empty) == .init(json: stoppedJSON, exitCode: 0), + "no running target succeeds with deterministic stopped JSON and no target files") + try check(empty.advances.isEmpty, "no running target needs no lifecycle wait") + + let otherCopy = temporary.appendingPathComponent("Other/Agent Queue.app") + let excluded = [ + AppFixture(path: otherCopy), AppFixture(path: target, identifier: "io.went.unrelated"), + AppFixture(path: target, uid: 502), AppFixture(path: target, pid: 99), + AppFixture(path: nil), AppFixture(path: target, identifier: nil), + AppFixture(path: target, pid: -1), AppFixture(path: URL(string: "https://example.invalid/Agent%20Queue.app")), + AppFixture(path: URL(fileURLWithPath: "/Applications/Agent Queue.app")) + ] + let exclusions = ReplacementFixture() + exclusions.apps = excluded + try check(try prepare(exclusions)?.exitCode == 0, "unrelated copies and foreign processes do not block target") + try check(excluded.allSatisfy { $0.terminationRequests == 0 }, + "other path, identifier, UID, own PID and missing identity are never terminated") + let unknownOwner = ReplacementFixture() + unknownOwner.apps = [AppFixture(path: target, uid: nil)] + try replacementFailure(.unverifiedOwnership, "unreadable owner of exact live app fails closed") { + _ = try prepare(unknownOwner) + } + try check(unknownOwner.apps[0].terminationRequests == 0, "unverified owner receives no quit request") + + let live = ReplacementFixture() + let matchingApp = AppFixture(path: target) + live.apps = excluded + [matchingApp] + try check(try prepare(live)?.json == stoppedJSON, "exact live metadata authorizes missing target repair") + try check(matchingApp.terminationRequests == 1 && excluded.allSatisfy { $0.terminationRequests == 0 }, + "only verified target receives one polite quit request") + let systemInstall = ReplacementFixture() + systemInstall.apps = [AppFixture(path: URL(fileURLWithPath: "/Applications/Agent Queue.app")), + AppFixture(path: target)] + let systemResult = try Replacement.handle( + arguments: ["--prepare-app-replacement", "/Applications/Agent Queue.app"], owningBundle: owner, + home: home, currentUserID: 501, currentProcessID: 99, makeAdapter: { systemInstall.adapter }) + try check(systemResult == .init(json: "{\"app\":\"/Applications/Agent Queue.app\",\"schemaVersion\":1,\"status\":\"stopped\"}", exitCode: 0), + "system install returns the same deterministic replacement contract") + try check(systemInstall.apps[0].terminationRequests == 1 && systemInstall.apps[1].terminationRequests == 0, + "system target never terminates independently installed user copy") + for renamed in [home.appendingPathComponent("Applications/Finder Copied Queue.app"), + URL(fileURLWithPath: "/Applications/Finder Copied Queue.app")] { + let fixture = ReplacementFixture() + fixture.apps = [AppFixture(path: renamed), AppFixture(path: target)] + let result = try Replacement.handle( + arguments: ["--prepare-app-replacement", renamed.path], owningBundle: owner, + home: home, currentUserID: 501, currentProcessID: 99, makeAdapter: { fixture.adapter }) + try check(result?.exitCode == 0 && result?.json.contains(renamed.path) == true, + "renamed Applications target retains exact shutdown identity") + try check(fixture.apps[0].terminationRequests == 1 && fixture.apps[1].terminationRequests == 0, + "renamed target quits only its own verified process") + } + + // A broken target is accepted solely using live metadata. No Bundle is + // loaded from the target, and the label alone never authorizes a quit. + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + try Data("damaged plist".utf8).write(to: target.appendingPathComponent("Info.plist")) + let damaged = ReplacementFixture() + damaged.apps = [AppFixture(path: target)] + try check(try prepare(damaged)?.exitCode == 0 && damaged.apps[0].terminationRequests == 1, + "damaged disk bundle is repairable from verified live bundle metadata") + let labelOnly = ReplacementFixture() + labelOnly.apps = [AppFixture(path: target, identifier: "io.went.impostor")] + try check(try prepare(labelOnly)?.exitCode == 0 && labelOnly.apps[0].terminationRequests == 0, + "matching app label never substitutes for live bundle identifier") + try FileManager.default.removeItem(at: target) + try FileManager.default.createDirectory(at: otherCopy, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: target, withDestinationURL: otherCopy) + try replacementFailure(.invalidTarget, "canonical install location cannot redirect to another copy") { + _ = try Replacement.validateTarget(target.path, home: home) + } + try FileManager.default.removeItem(at: target) + + let alreadyStopped = ReplacementFixture() + alreadyStopped.apps = [AppFixture(path: target)] + alreadyStopped.apps[0].terminated = true + try check(try prepare(alreadyStopped)?.exitCode == 0 && alreadyStopped.apps[0].terminationRequests == 0, + "already exited live record receives no quit request") + alreadyStopped.apps[0].metadata = .init(processID: 42, bundleURL: target, + bundleIdentifier: "io.went.agent-queue", userID: nil) + try check(try prepare(alreadyStopped)?.exitCode == 0 && alreadyStopped.apps[0].terminationRequests == 0, + "exited app needs no process owner after its process metadata disappears") + let refused = ReplacementFixture() + refused.apps = [AppFixture(path: target)] + refused.apps[0].acceptsTermination = false + try replacementFailure(.terminationRefused, "refused polite quit fails replacement") { _ = try prepare(refused) } + try check(refused.apps[0].terminationRequests == 1 && refused.advances.isEmpty, + "refusal is not retried or escalated") + let asynchronous = ReplacementFixture() + asynchronous.apps = [AppFixture(path: target), AppFixture(path: target, pid: 43)] + for app in asynchronous.apps { app.finishImmediately = false } + asynchronous.onAdvance = { + if asynchronous.time >= 0.1 { asynchronous.apps[0].terminated = true } + if asynchronous.time >= 0.2 { asynchronous.apps[1].terminated = true } + } + try check(try prepare(asynchronous)?.exitCode == 0 && asynchronous.time >= 0.2, + "replacement waits for every matching instance through injected run-loop turns") + try check(asynchronous.apps.allSatisfy { $0.terminationRequests == 1 } + && asynchronous.advances.allSatisfy { $0 > 0 && $0 <= 0.05 }, + "asynchronous quit sends once and advances bounded event intervals") + let timeout = ReplacementFixture() + timeout.apps = [AppFixture(path: target)] + timeout.apps[0].finishImmediately = false + try replacementFailure(.terminationTimedOut, "accepted request without exit times out") { _ = try prepare(timeout) } + try check(timeout.time == 30 && timeout.apps[0].terminationRequests == 1, + "timeout is bounded to 30 simulated seconds and never force kills") + + let changed = ReplacementFixture() + let changingApp = AppFixture(path: target) + changed.apps = [changingApp] + var metadataReads = 0 + changingApp.onMetadata = { + metadataReads += 1 + if metadataReads == 2 { + changingApp.metadata = .init(processID: 42, bundleURL: target, + bundleIdentifier: "io.went.agent-queue", userID: 502) + } + } + try replacementFailure(.processChanged, "identity is checked again immediately before polite quit") { + _ = try prepare(changed) + } + try check(changingApp.terminationRequests == 0, "changed ownership receives no quit request") + let relaunched = ReplacementFixture() + relaunched.apps = [AppFixture(path: target)] + relaunched.onEnumeration = { + if relaunched.enumerationCount == 2 { relaunched.apps.append(AppFixture(path: target, pid: 43)) } + } + try replacementFailure(.processChanged, "new instance during shutdown blocks payload replacement") { + _ = try prepare(relaunched) + } + try check(relaunched.apps[1].terminationRequests == 0, "newly launched instance is never chased") + + let replacementError = NSError(domain: "ServiceManagementTests.replacement", code: 9) + for stage in ["enumeration", "metadata", "observation", "termination", "runloop"] { + let errors = ReplacementFixture() + let app = AppFixture(path: target) + errors.apps = [app] + switch stage { + case "enumeration": errors.enumerationError = replacementError + case "metadata": app.metadataError = replacementError + case "observation": app.observationError = replacementError + case "termination": app.terminationError = replacementError + default: + app.finishImmediately = false + errors.advanceError = replacementError + } + try propagated(replacementError, "replacement \(stage) error propagates unchanged") { + _ = try prepare(errors) + } + try check(app.terminationRequests <= 1, "replacement \(stage) error never retries termination") + } + try propagated(replacementError, "replacement adapter construction error propagates") { + _ = try Replacement.handle(arguments: replacementArguments, owningBundle: owner, + home: home, currentUserID: 501, currentProcessID: 99) { throw replacementError } + } + print("PASS \(passed) service-management checks; all mutations simulated") + } +} diff --git a/native/tests/UpdateTests.swift b/native/tests/UpdateTests.swift index 1a62794..3961f87 100644 --- a/native/tests/UpdateTests.swift +++ b/native/tests/UpdateTests.swift @@ -56,7 +56,7 @@ actor UpdateFixture { if arguments == ["updates", "install", "--build", "101", "--json"] { if failInstall { throw ClientError.message("Checksum mismatch") } return try encoder.encode(QueueInstallerResult(installerOpened: true, build: installerBuild, - message: "Installer opened. Choose Install to finish. Queued and running jobs are preserved")) + message: "Installer opened. Finish queued and running jobs. Choose Install to continue; active work blocks managed service changes")) } throw ClientError.message("Unexpected update command") } @@ -201,10 +201,18 @@ struct UpdateTests { let callsAfterPreview = await fixture.calls try check(callsAfterPreview == callsBeforePreview && preview.check?.latest?.build == 101, "Snapshot fixture has a simulated release and performs no external effects") + for role in ["queue", "resources", "remote", "login"] { + try check(QueueClient.timeout(for: ["--service-management", "status", role], budget: .setup) == 5, + "Exact native status for \(role) has a five-second read budget") + } + try check(QueueClient.timeout(for: ["--service-management", "register", "queue"], budget: .setup) == 330 && + QueueClient.timeout(for: ["--service-management", "status", "queue", "extra"], budget: .setup) == 330 && + QueueClient.timeout(for: ["--service-management", "status", "unknown"], budget: .setup) == 330, + "Native mutation and malformed arguments cannot inherit discardable read budgets") try check(QueueClient.timeout(for: ["status"], budget: .updates) == 8 && QueueClient.timeout(for: ["cancel", "1"]) == 25 && QueueClient.timeout(for: ["status"], controller: .remote) == 8 && - QueueClient.timeout(for: ["configure"], controller: .remote) == 35 && + QueueClient.timeout(for: ["configure"], controller: .remote) == 125 && QueueClient.timeout(for: ["updates", "install"], budget: .updates) == 130 && QueueClient.timeout(for: ["updates", "install"]) == 25, "Explicit update deadline leaves normal queue and remote deadlines unchanged") diff --git a/native/tests/run-bootstrap-tests.sh b/native/tests/run-bootstrap-tests.sh index ac7231d..71fb509 100644 --- a/native/tests/run-bootstrap-tests.sh +++ b/native/tests/run-bootstrap-tests.sh @@ -5,8 +5,8 @@ root=$(cd -- "$(dirname -- "$0")/../.." && pwd) temporary=$(mktemp -d) trap 'rm -rf -- "$temporary"' EXIT sed '/^@main$/d' "$root/native/AgentQueue.swift" > "$temporary/AgentQueue.swift" -xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit \ +xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit -framework ServiceManagement \ "$root/native/QueueBrand.swift" "$temporary/AgentQueue.swift" "$root/native/AgentIntegration.swift" "$root/native/AgentUpdates.swift" \ - "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" \ + "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" "$root/native/AgentServices.swift" \ "$root/native/tests/BootstrapFixtures.swift" "$root/native/tests/BootstrapTests.swift" -o "$temporary/BootstrapTests" "$temporary/BootstrapTests" diff --git a/native/tests/run-bootstrap-ui-tests.sh b/native/tests/run-bootstrap-ui-tests.sh index b32cdef..9ec0fd3 100644 --- a/native/tests/run-bootstrap-ui-tests.sh +++ b/native/tests/run-bootstrap-ui-tests.sh @@ -29,9 +29,9 @@ cat > "$app/Contents/Info.plist" <<'PLIST' PLIST sed '/^@main$/d' "$root/native/AgentQueue.swift" > "$temporary/AgentQueue.swift" -xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit \ +xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit -framework ServiceManagement \ "$root/native/QueueBrand.swift" "$temporary/AgentQueue.swift" "$root/native/AgentIntegration.swift" "$root/native/AgentUpdates.swift" \ - "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" \ + "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" "$root/native/AgentServices.swift" \ "$root/native/tests/BootstrapFixtures.swift" "$root/native/tests/BootstrapUITests.swift" -o "$app/Contents/MacOS/BootstrapUITests" if [[ "${1:-}" == "--build-only" ]]; then exit 0; fi if [[ $# -ne 0 ]]; then echo "Unknown bootstrap UI fixture argument" >&2; exit 2; fi diff --git a/native/tests/run-menu-bar-ui-tests.sh b/native/tests/run-menu-bar-ui-tests.sh index 67ea4ee..c337923 100755 --- a/native/tests/run-menu-bar-ui-tests.sh +++ b/native/tests/run-menu-bar-ui-tests.sh @@ -43,9 +43,9 @@ cat > "$fixture_app/Contents/Info.plist" <<'PLIST' PLIST /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier io.went.agentqueue.fixture.$(uuidgen)" "$fixture_app/Contents/Info.plist" sed '/^@main$/d' "$root/native/AgentQueue.swift" > "$temporary/AgentQueue.swift" -xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit \ +xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit -framework ServiceManagement \ "$root/native/QueueBrand.swift" "$temporary/AgentQueue.swift" "$root/native/AgentIntegration.swift" "$root/native/AgentUpdates.swift" \ - "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" "$root/native/tests/MenuBarUITests.swift" -o "$fixture_binary" + "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" "$root/native/AgentServices.swift" "$root/native/tests/MenuBarUITests.swift" -o "$fixture_binary" rm -f -- "$report" open -W -n --stdout "$temporary/stdout.log" --stderr "$temporary/stderr.log" "$fixture_app" \ --args --fixture "$root/native/tests/fixtures/queue.json" --report "$report" --pid-file "$fixture_pid_file" "$@" & diff --git a/native/tests/run-refresh-tests.sh b/native/tests/run-refresh-tests.sh index fe49fc7..6ab72dc 100644 --- a/native/tests/run-refresh-tests.sh +++ b/native/tests/run-refresh-tests.sh @@ -5,6 +5,6 @@ temporary=$(mktemp -d) trap 'rm -rf "$temporary"' EXIT # Keep the real store and views; replace only the app entry point with the test entry point. sed '/^@main$/d' "$source_dir/../AgentQueue.swift" > "$temporary/AgentQueue.swift" -xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit \ - "$source_dir/../QueueBrand.swift" "$temporary/AgentQueue.swift" "$source_dir/../AgentIntegration.swift" "$source_dir/../AgentUpdates.swift" "$source_dir/../AgentLearning.swift" "$source_dir/../AgentBootstrap.swift" "$source_dir/RefreshTests.swift" -o "$temporary/RefreshTests" +xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit -framework ServiceManagement \ + "$source_dir/../QueueBrand.swift" "$temporary/AgentQueue.swift" "$source_dir/../AgentIntegration.swift" "$source_dir/../AgentUpdates.swift" "$source_dir/../AgentLearning.swift" "$source_dir/../AgentBootstrap.swift" "$source_dir/../AgentServices.swift" "$source_dir/RefreshTests.swift" -o "$temporary/RefreshTests" "$temporary/RefreshTests" diff --git a/native/tests/run-service-management-tests.sh b/native/tests/run-service-management-tests.sh new file mode 100755 index 0000000..fec9348 --- /dev/null +++ b/native/tests/run-service-management-tests.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Inert lifecycle tests. Never registers a service or terminates a real app. +set -euo pipefail +root=$(cd -- "$(dirname -- "$0")/../.." && pwd) +temporary=$(mktemp -d) +trap 'rm -rf -- "$temporary"' EXIT + +# Both distributed architectures must compile against the macOS 15 API floor. +for architecture in arm64 x86_64; do + xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone \ + -target "$architecture-apple-macos15.0" -framework Foundation -framework AppKit -framework ServiceManagement \ + "$root/native/AgentServices.swift" "$root/native/tests/ServiceManagementTests.swift" \ + -o "$temporary/ServiceManagementTests-$architecture" +done +case "$(uname -m)" in + arm64|x86_64) "$temporary/ServiceManagementTests-$(uname -m)" ;; + *) echo "Unsupported test architecture" >&2; exit 1 ;; +esac diff --git a/packaging/homebrew/refresh.go b/packaging/homebrew/refresh.go index 935549c..2b1c763 100644 --- a/packaging/homebrew/refresh.go +++ b/packaging/homebrew/refresh.go @@ -749,28 +749,20 @@ const caskTemplate = `cask "agent-job-queue" do # Preserve quarantine so macOS can assess the notarized release. - application = "#{Dir.home}/Applications/Agent Queue.app" - app "Agent Job Queue Installer.app/Contents/Resources/payload/Agent Queue.app", target: application + # Homebrew owns delivery of the explicit installer, never the running app. + app "Agent Job Queue Installer.app", target: "#{Dir.home}/Applications/Agent Job Queue Installer.app" - # Stable per-user wrappers survive staged cask cleanup and future upgrades. binary "Agent Job Queue Installer.app/Contents/Resources/payload/source/links/agent-heavy" binary "Agent Job Queue Installer.app/Contents/Resources/payload/source/links/agent-remote" - # The app artifact owns app removal and upgrade rollback. - uninstall quit: "io.went.agent-queue", - script: { - executable: "/bin/rm", - args: ["-f", "--", "#{Dir.home}/Library/LaunchAgents/io.went.agent-queue.plist"], - sudo: false, - } - caveats <<~EOS - Installs Agent Queue for this account without Python, uv, or a Go toolchain. - Open Agent Queue to finish setup. Command-line tools are ready after setup completes. - Release installer and app are Developer ID signed and notarized. - Quarantine is retained. Phone access is off until enabled. - Uninstall preserves the independent queue, jobs, credentials and remote access. - Disable phone access in settings before uninstall to stop its HTTPS service. + Open Agent Job Queue Installer in this account's Applications folder and choose Install. + Installation, upgrades and service approval happen in that explicit installer. + Finish queued and running jobs first. CLI links become ready after setup. + Homebrew delivers the installer; its sandbox cannot manage the queue's services. + Homebrew upgrade or uninstall leaves the installed Agent Queue app and services in place. + To remove Agent Queue, use its explicit service-removal command before deleting the app. + Release installer and app are Developer ID signed and notarized. Quarantine is retained. EOS end ` diff --git a/packaging/homebrew/refresh_test.go b/packaging/homebrew/refresh_test.go index d1d095e..f46c69f 100644 --- a/packaging/homebrew/refresh_test.go +++ b/packaging/homebrew/refresh_test.go @@ -315,8 +315,13 @@ func TestRenderedCaskRetainsQuarantine(t *testing.T) { t.Fatalf("rendered cask contains quarantine bypass: %s", unsafe) } } - if !strings.Contains(cask, `app "Agent Job Queue Installer.app/Contents/Resources/payload/Agent Queue.app", target: application`) || !strings.Contains(cask, "Open Agent Queue to finish setup") || strings.Contains(cask, "installer script:") || strings.Contains(cask, "postflight") || strings.Contains(cask, `"--payload"`) { - t.Fatal("rendered cask must install the Homebrew-managed app and defer setup to its native first launch") + if !strings.Contains(cask, `app "Agent Job Queue Installer.app", target: "#{Dir.home}/Applications/Agent Job Queue Installer.app"`) || !strings.Contains(cask, "Open Agent Job Queue Installer") || !strings.Contains(cask, "choose Install") || !strings.Contains(cask, "CLI links become ready after setup") || !strings.Contains(cask, "Homebrew upgrade or uninstall leaves the installed Agent Queue app and services in place") { + t.Fatal("rendered cask must deliver the explicit installer and retain the independently installed runtime") + } + for _, forbidden := range []string{"preflight", "postflight", "installer script:", "uninstall quit:", "uninstall script:", "system_command", "run \"", `"--payload"`, `"--app-bundle"`, "payload/Agent Queue.app\", target:"} { + if strings.Contains(cask, forbidden) { + t.Fatal("rendered delivery cask contains executable setup or runtime app ownership", forbidden) + } } if !strings.Contains(cask, "Developer ID signed and notarized") || !strings.Contains(cask, "Quarantine is retained") { t.Fatal("rendered cask is missing notarized release provenance") diff --git a/packaging/homebrew/test-cask.rb b/packaging/homebrew/test-cask.rb index 8fa0f1b..21c1ab0 100644 --- a/packaging/homebrew/test-cask.rb +++ b/packaging/homebrew/test-cask.rb @@ -3,8 +3,8 @@ # Load the real Homebrew DSL. Optional fixture mode moves its app and verifies # its bytes with a temporary HOME and shared Caskroom supplied by tests. # --quarantined verifies data only. --quarantined-runtime requires a notarized -# Developer ID payload and installs its app into a private HOME. Native first- -# launch setup acceptance runs separately after the Homebrew app installation. +# Developer ID payload and verifies relocated installer bytes. Explicit setup +# and simulated native lifecycle run separately in the parent, outside Homebrew. require "json" require "find" require "cask/cask_loader" @@ -80,6 +80,31 @@ def exec3(*args, **options) end end +# Homebrew delivers the explicit installer. Only its native App movement and +# two CLI symlinks are allowed; setup and runtime lifecycle belong to the user. +def verify_delivery_cask_contract(cask) + installer = cask.staged_path/"Agent Job Queue Installer.app" + applications = cask.artifacts.grep(Cask::Artifact::App) + target = Pathname(Dir.home)/"Applications/Agent Job Queue Installer.app" + unless applications.length == 1 && applications.first.source == installer && applications.first.target == target + raise "The cask must deliver only the explicit installer app to this user's Applications directory" + end + allowed = [Cask::Artifact::App, Cask::Artifact::Binary] + unless cask.artifacts.all? { |artifact| allowed.include?(artifact.class) } + raise "Delivery-only cask cannot contain executable hooks, uninstall directives or other artifacts" + end + binaries = cask.artifacts.grep(Cask::Artifact::Binary) + source = installer/"Contents/Resources/payload/source/links" + unless binaries.map(&:source).sort == %w[agent-heavy agent-remote].map { |name| source/name }.sort && + binaries.all? { |binary| binary.target == cask.config.binarydir/binary.source.basename } + raise "Both canonical CLI links are required" + end + artifacts = cask.artifacts.to_a + unless binaries.all? { |binary| artifacts.index(applications.first) < artifacts.index(binary) } + raise "Installer delivery must precede its CLI links" + end +end + # Allow the timeout regression to use this exact command owner without running # the cask loader or evaluating a synthetic release as a notarized artifact. return if __FILE__ != $PROGRAM_NAME @@ -92,14 +117,35 @@ def exec3(*args, **options) fixture_command = SystemCommand raise "Runtime mode requires a private fixture" if runtime_fixture && !ARGV[1] +def app_helpers(app) + info = IO.popen(["/usr/bin/plutil", "-convert", "json", "-o", "-", (app/"Contents/Info.plist").to_s], &:read) + raise "Cannot read app metadata" unless $?.success? + + metadata = JSON.parse(info) + if metadata.key?("AgentQueueServiceManagement") + raise "Invalid managed marker" unless metadata.fetch("AgentQueueServiceManagement") == true + + base = app/"Contents/MacOS" + elsif (app/"Contents/Resources/payload").exist? + base = app/"Contents/Resources/payload/source/bin" + else + return [] + end + %w[agent-heavy agent-remote].map { |name| base/name } +end + def verify_runtime_installer(installer, team, command) payload = installer/"Contents/Resources/payload" requirement = "=anchor apple generic and certificate leaf[subject.OU] = \"#{team}\" and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and notarized" - [installer, payload/"source/bin/agent-heavy", payload/"source/bin/agent-remote", payload/"Agent Queue.app", - payload/"Agent Queue.app/Contents/Resources/payload/source/bin/agent-heavy", - payload/"Agent Queue.app/Contents/Resources/payload/source/bin/agent-remote"].each do |code| + app = payload/"Agent Queue.app" + codes = [installer, payload/"source/bin/agent-heavy", payload/"source/bin/agent-remote", app] + codes.each do |code| + command.run!("/usr/bin/codesign", args: ["--verify", "--strict", "--all-architectures", "--check-notarization", "--test-requirement", requirement, code]) + end + app_helpers(app).each do |code| command.run!("/usr/bin/codesign", args: ["--verify", "--strict", "--all-architectures", "--check-notarization", "--test-requirement", requirement, code]) end + rescue ErrorDuringExecution => e raise "Runtime fixture failed Developer ID/notarization verification: #{e.message}" end @@ -140,28 +186,17 @@ def verify_runtime_installer(installer, team, command) cask = Cask::CaskLoader.load(cask_path) payload = cask.staged_path/"Agent Job Queue Installer.app/Contents/Resources/payload" applications = cask.artifacts.grep(Cask::Artifact::App) -application = Pathname(Dir.home)/"Applications/Agent Queue.app" -unless applications.length == 1 && applications.first.source == payload/"Agent Queue.app" && applications.first.target == application - raise "The cask must install one Homebrew-managed app in this user's Applications directory" -end -hook_classes = [Cask::Artifact::Installer, Cask::Artifact::PreflightBlock, Cask::Artifact::PostflightBlock] -hook_classes << Cask::Artifact::PreflightSteps if defined?(Cask::Artifact::PreflightSteps) -hook_classes << Cask::Artifact::PostflightSteps if defined?(Cask::Artifact::PostflightSteps) -raise "Setup belongs to native first launch; executable install hooks are forbidden" if cask.artifacts.any? { |artifact| hook_classes.any? { |klass| artifact.is_a?(klass) } } +application = Pathname(Dir.home)/"Applications/Agent Job Queue Installer.app" +verify_delivery_cask_contract(cask) binaries = cask.artifacts.select { |artifact| artifact.is_a?(Cask::Artifact::Binary) } raise "Both CLI binaries are required" unless binaries.map { |artifact| artifact.source.basename.to_s }.sort == %w[agent-heavy agent-remote] artifacts = cask.artifacts.to_a -uninstalls = cask.artifacts.grep(Cask::Artifact::Uninstall) -unless uninstalls.length == 1 && uninstalls.first.to_h == { quit: "io.went.agent-queue", script: { executable: "/bin/rm", args: ["-f", "--", "#{Dir.home}/Library/LaunchAgents/io.went.agent-queue.plist"], sudo: false }, signal: [] } - raise "Only the app launch agent may be deleted; Homebrew must own app removal and rollback" -end -raise "Live state must never be zapped" if cask.artifacts.any? { |artifact| artifact.is_a?(Cask::Artifact::Zap) } - fixture_attributes = nil fixture_app_installed = false +fixture_installer_executed = false if ARGV[1] raise "Fixture install requires explicit test mode" unless ENV.fetch("AGENT_QUEUE_INSTALL_TEST_MODE") == "1" @@ -173,7 +208,7 @@ def verify_runtime_installer(installer, team, command) # starts. Retain the actual signed installer, including its stapled ticket. fixture_command.run!("/usr/bin/ditto", args: ["--rsrc", "--extattr", "--qtn", "--acl", fixture/"Agent Job Queue Installer.app", cask.staged_path/"Agent Job Queue Installer.app"]) else - FileUtils.cp_r(fixture/"payload", payload) + FileUtils.cp_r((fixture/"Agent Job Queue Installer.app").children, cask.staged_path/"Agent Job Queue Installer.app") end if %w[--quarantined --quarantined-runtime].include?(ARGV[2]) entries = Find.find(cask.staged_path.to_s).to_a @@ -190,16 +225,9 @@ def verify_runtime_installer(installer, team, command) end # Recheck the actual quarantined staged bytes before native app relocation. verify_runtime_installer(cask.staged_path/"Agent Job Queue Installer.app", team, fixture_command) if runtime_fixture - if !fixture_attributes || runtime_fixture - applications.first.install_phase(command: fixture_command) - if runtime_fixture - requirement = "=anchor apple generic and certificate leaf[subject.OU] = \"#{team}\" and certificate 1[field.1.2.840.113635.100.6.2.6] exists and notarized" - [application, application/"Contents/Resources/payload/source/bin/agent-heavy", application/"Contents/Resources/payload/source/bin/agent-remote"].each do |code| - fixture_command.run!("/usr/bin/codesign", args: ["--verify", "--strict", "--all-architectures", "--check-notarization", "--test-requirement", requirement, code]) - end - end - fixture_app_installed = true - end + applications.first.install_phase + verify_runtime_installer(application, team, fixture_command) if runtime_fixture + fixture_app_installed = true if fixture_attributes # App relocation leaves a staging symlink. Inspect that link itself, then # the installed app separately, so no bundle entry is followed or counted twice. @@ -219,4 +247,4 @@ def verify_runtime_installer(installer, team, command) artifact_order: artifacts.map { |artifact| artifact.class.name }, fixture_attributes: fixture_attributes, fixture_app_installed: fixture_app_installed, - fixture_installer_executed: false }) + fixture_installer_executed: fixture_installer_executed }) diff --git a/packaging/links/agent-heavy b/packaging/links/agent-heavy index 7ea5119..eb6dbaf 100755 --- a/packaging/links/agent-heavy +++ b/packaging/links/agent-heavy @@ -1,3 +1,8 @@ #!/bin/sh set -eu -exec "${HOME:?}/.local/share/agent-heavy/bin/agent-heavy" "$@" +entry="${HOME:?}/.local/bin/agent-heavy" +if [ ! -x "$entry" ]; then + echo 'Agent Queue needs setup. Open Agent Job Queue Installer from Applications and choose Install.' >&2 + exit 125 +fi +exec "$entry" "$@" diff --git a/packaging/links/agent-remote b/packaging/links/agent-remote index 0d7efff..08987fb 100755 --- a/packaging/links/agent-remote +++ b/packaging/links/agent-remote @@ -1,3 +1,8 @@ #!/bin/sh set -eu -exec "${HOME:?}/.local/share/agent-heavy/bin/agent-remote" "$@" +entry="${HOME:?}/.local/bin/agent-remote" +if [ ! -x "$entry" ]; then + echo 'Agent Queue needs setup. Open Agent Job Queue Installer from Applications and choose Install.' >&2 + exit 125 +fi +exec "$entry" "$@" diff --git a/scripts/build-installer.sh b/scripts/build-installer.sh index 3b8a58f..ee8b776 100755 --- a/scripts/build-installer.sh +++ b/scripts/build-installer.sh @@ -21,7 +21,7 @@ mkdir -p "$installer/Contents/MacOS" "$app/Contents/MacOS" "$app/Contents/Resour ARCHS='arm64 x86_64' "$root/native/build.sh" "$app/Contents/MacOS/AgentQueue" for architecture in arm64 x86_64; do xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -O -target "$architecture-apple-macos15.0" \ - -framework SwiftUI -framework AppKit "$root/native/QueueBrand.swift" "$root/native/Installer.swift" -o "$work/Installer-$architecture" + -framework SwiftUI -framework AppKit -framework ServiceManagement "$root/native/QueueBrand.swift" "$root/native/AgentServices.swift" "$root/native/Installer.swift" -o "$work/Installer-$architecture" goarch=$architecture [[ "$architecture" != x86_64 ]] || goarch=amd64 for command in agent-heavy agent-remote; do @@ -64,6 +64,7 @@ cat > "$app/Contents/Info.plist" <LSUIElement NSHighResolutionCapable AgentQueueEmbeddedPayload +AgentQueueServiceManagement PLIST cat > "$installer/Contents/Info.plist" < "$installer/Contents/Info.plist" < PLIST plutil -lint "$app/Contents/Info.plist" "$installer/Contents/Info.plist" -# The menu app also carries its own setup payload so a Finder copy is complete. +# The app carries support metadata; its only helper copies live in MacOS. # Copy only source support, never the app or its enclosing installer. mkdir -p "$app/Contents/Resources/payload" ditto "$payload/source" "$app/Contents/Resources/payload/source" +for command in agent-heavy agent-remote; do + mv "$app/Contents/Resources/payload/source/bin/$command" "$app/Contents/MacOS/$command" +done +rmdir "$app/Contents/Resources/payload/source/bin" +mkdir -p "$app/Contents/Library/LaunchAgents" +cp "$root"/native/LaunchAgents/*.plist "$app/Contents/Library/LaunchAgents/" +plutil -lint "$app"/Contents/Library/LaunchAgents/*.plist macos_package_installer "$installer" "$destination" "$version" diff --git a/scripts/macos-resume.sh b/scripts/macos-resume.sh index 29209c6..7cfd46a 100755 --- a/scripts/macos-resume.sh +++ b/scripts/macos-resume.sh @@ -422,6 +422,7 @@ if [[ $command == init ]]; then for path in 'Agent Queue.app/Contents/MacOS/AgentQueue' source/bin/agent-heavy source/bin/agent-remote; do [[ -f "$state/work/Agent Job Queue Installer.app/Contents/Resources/payload/$path" ]] || fail 'The complete installer payload is required.' done + macos_app_payload_mode "$state/work/Agent Job Queue Installer.app/Contents/Resources/payload/Agent Queue.app" archive "$state/work/Agent Job Queue Installer.app" "$state/initial.zip" jq -n --arg version "$resume_version" --arg team "$APPLE_TEAM_ID" --arg sha "$source_sha" \ --arg repository "$GITHUB_REPOSITORY" --arg run "$source_run" --arg number "$source_number" \ diff --git a/scripts/macos-signing.sh b/scripts/macos-signing.sh index 6bfc9b5..8b5df48 100755 --- a/scripts/macos-signing.sh +++ b/scripts/macos-signing.sh @@ -372,13 +372,12 @@ macos_assess_app() { } macos_verify_installer_code() { - local installer=$1 payload="$1/Contents/Resources/payload" name embedded - embedded="$payload/Agent Queue.app/Contents/Resources/payload/source" + local installer=$1 payload="$1/Contents/Resources/payload" name macos_app_payload_mode "$payload/Agent Queue.app" || return for name in agent-heavy agent-remote; do macos_verify_code "$payload/source/bin/$name" || return if [[ $MACOS_APP_EMBEDDED == 1 ]]; then - macos_verify_code "$embedded/bin/$name" || return + macos_verify_code "$MACOS_APP_HELPERS/$name" || return fi done macos_verify_code "$payload/Agent Queue.app" || return @@ -389,18 +388,25 @@ macos_verify_installer_code() { # Old retained build artifacts lack self-contained menu apps. Keep their custom # installer packaging intact when newer distribution automation replays them. macos_app_payload_mode() { - local app=$1 payload="$1/Contents/Resources/payload" name + local app=$1 payload="$1/Contents/Resources/payload" name marker service helper command definition MACOS_APP_EMBEDDED=0 + MACOS_APP_MANAGED=0 + MACOS_APP_HELPERS="$payload/source/bin" + if marker=$(plutil -type AgentQueueServiceManagement "$app/Contents/Info.plist" 2>/dev/null); then + [[ $marker == bool && $(plutil -extract AgentQueueServiceManagement raw -o - "$app/Contents/Info.plist") == true ]] || { macos_signing_error 'Invalid service management packaging marker.'; return 1; } + MACOS_APP_MANAGED=1 + MACOS_APP_HELPERS="$app/Contents/MacOS" + fi if [[ -L $payload ]]; then macos_signing_error 'The menu app setup payload must not be a symlink.'; return 1; fi if [[ ! -e $payload ]]; then - if plutil -extract AgentQueueEmbeddedPayload raw -o - "$app/Contents/Info.plist" >/dev/null 2>&1; then + if [[ $MACOS_APP_MANAGED == 1 ]] || plutil -extract AgentQueueEmbeddedPayload raw -o - "$app/Contents/Info.plist" >/dev/null 2>&1; then macos_signing_error 'The menu app declares an embedded setup payload but it is missing.'; return 1; fi return 0 fi - for name in "$payload" "$payload/source" "$payload/source/bin"; do + for name in "$payload" "$payload/source" "$MACOS_APP_HELPERS"; do [[ -d $name && ! -L $name ]] || { macos_signing_error 'The menu app setup payload is incomplete or linked.'; return 1; } @@ -409,19 +415,45 @@ macos_app_payload_mode() { macos_signing_error 'The menu app setup payload is incomplete.'; return 1; } for name in agent-heavy agent-remote; do - [[ -f "$payload/source/bin/$name" && ! -L "$payload/source/bin/$name" && -x "$payload/source/bin/$name" ]] || { + [[ -f "$MACOS_APP_HELPERS/$name" && ! -L "$MACOS_APP_HELPERS/$name" && -x "$MACOS_APP_HELPERS/$name" ]] || { macos_signing_error 'The menu app setup helper is missing or not executable.'; return 1; } done + if [[ $MACOS_APP_MANAGED == 1 ]]; then + [[ ! -e $payload/source/bin && ! -L $payload/source/bin ]] || { + macos_signing_error 'Managed app must not embed source/bin.'; return 1; + } + [[ -d $app/Contents/Library && ! -L $app/Contents/Library && -d $app/Contents/Library/LaunchAgents && ! -L $app/Contents/Library/LaunchAgents ]] || { + macos_signing_error 'Managed app LaunchAgents directory is missing or linked.'; return 1; + } + for service in heavy resources remote; do + helper=agent-heavy; command=daemon + case $service in resources) command=scheduler ;; remote) helper=agent-remote; command=serve ;; esac + definition="$app/Contents/Library/LaunchAgents/io.went.agent-$service.plist" + [[ -f $definition && ! -L $definition ]] || { macos_signing_error 'Managed app LaunchAgent is missing or linked.'; return 1; } + plutil -convert json -o - "$definition" | jq -e --arg service "$service" --arg helper "$helper" --arg command "$command" ' + . == ({Label:("io.went.agent-"+$service), BundleProgram:("Contents/MacOS/"+$helper), + ProgramArguments:[$helper,$command], ThrottleInterval:10, Umask:63, ProcessType:"Background"} + + if $service == "resources" then {KeepAlive:true} else {RunAtLoad:true,KeepAlive:{SuccessfulExit:false}} end) + ' >/dev/null || { macos_signing_error 'Invalid managed app LaunchAgent definition.'; return 1; } + done + # No fourth service (including a legacy /usr/bin/open login item) is allowed. + local count=0 entry + for entry in "$app"/Contents/Library/LaunchAgents/* "$app"/Contents/Library/LaunchAgents/.[!.]* "$app"/Contents/Library/LaunchAgents/..?*; do + [[ -e $entry || -L $entry ]] || continue + count=$((count + 1)) + done + [[ $count == 3 ]] || { macos_signing_error 'Unexpected managed app LaunchAgent.'; return 1; } + fi MACOS_APP_EMBEDDED=1 } macos_sign_app_helpers() { - local app=$1 source="$1/Contents/Resources/payload/source" name + local app=$1 name macos_app_payload_mode "$app" || return [[ $MACOS_APP_EMBEDDED == 1 ]] || return 0 for name in agent-heavy agent-remote; do - macos_sign_code "$source/bin/$name" "io.went.agent-queue.$name" "app-$name" || return + macos_sign_code "$MACOS_APP_HELPERS/$name" "io.went.agent-queue.$name" "app-$name" || return done } @@ -429,6 +461,9 @@ macos_disk_image_source() { local installer=$1 directory=$2 app="$1/Contents/Resources/payload/Agent Queue.app" MACOS_DISK_IMAGE_SOURCE=$installer macos_app_payload_mode "$app" || return + # Managed setup is an explicit installation operation. Keep its installer as + # the disk-image entry point; retained self-installing apps keep their layout. + [[ $MACOS_APP_MANAGED != 1 ]] || return 0 [[ $MACOS_APP_EMBEDDED == 1 ]] || return 0 mkdir "$directory" || return ditto --rsrc --extattr --qtn --acl "$app" "$directory/Agent Queue.app" || return diff --git a/scripts/test-dmg.sh b/scripts/test-dmg.sh index dabd0f3..e8874dc 100644 --- a/scripts/test-dmg.sh +++ b/scripts/test-dmg.sh @@ -38,10 +38,15 @@ if [[ -d $app && ! -L $app && ! -e $installer && ! -L $installer ]]; then bundle=$app command=verify-app argument=--app + helper_directory='Contents/Resources/payload/source/bin' + if plutil -type AgentQueueServiceManagement "$app/Contents/Info.plist" >/dev/null 2>&1; then + # verify-app checks the marker type and every bundled service definition. + helper_directory='Contents/MacOS' + fi executables=( 'Contents/MacOS/AgentQueue' - 'Contents/Resources/payload/source/bin/agent-heavy' - 'Contents/Resources/payload/source/bin/agent-remote' + "$helper_directory/agent-heavy" + "$helper_directory/agent-remote" ) elif [[ -d $installer && ! -L $installer && ! -e $app && ! -L $app && ! -e $applications && ! -L $applications ]]; then bundle=$installer diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index 32e0320..1efbd95 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -15,3 +15,5 @@ AGENT_QUEUE_TEST_INSTALLER_PAYLOAD="$installer/Contents/Resources/payload" \ go test -p 2 -parallel 2 ./internal/backend -run '^TestInstallerPackaged(Payload|AppBundle)$' -count=1 -v -timeout 3m AGENT_QUEUE_TEST_INSTALLER_PAYLOAD="$installer/Contents/Resources/payload" \ go test -p 2 -parallel 2 ./internal/remote -run '^TestRemotePackagedGateway$' -count=1 -v -timeout 3m +AGENT_QUEUE_TEST_INSTALLER_PAYLOAD="$installer/Contents/Resources/payload" \ + go test -race -p 2 -parallel 2 ./internal/tooling -run '^TestManagedInstallerPackagedNativeFixture$' -count=1 -v -timeout 3m diff --git a/scripts/test-native.sh b/scripts/test-native.sh index 645f1fa..e029197 100755 --- a/scripts/test-native.sh +++ b/scripts/test-native.sh @@ -3,20 +3,21 @@ set -euo pipefail root=$(cd -- "$(dirname -- "$0")/.." && pwd) zsh "$root/native/tests/run-refresh-tests.sh" bash "$root/native/tests/run-bootstrap-tests.sh" +bash "$root/native/tests/run-service-management-tests.sh" temporary=$(mktemp -d) trap 'rm -rf "$temporary"' EXIT sed '/^@main$/d' "$root/native/AgentQueue.swift" > "$temporary/AgentQueue.swift" -xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit \ - "$root/native/QueueBrand.swift" "$temporary/AgentQueue.swift" "$root/native/AgentIntegration.swift" "$root/native/AgentUpdates.swift" "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" "$root/native/tests/BehaviorTests.swift" -o "$temporary/BehaviorTests" +xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit -framework ServiceManagement \ + "$root/native/QueueBrand.swift" "$temporary/AgentQueue.swift" "$root/native/AgentIntegration.swift" "$root/native/AgentUpdates.swift" "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" "$root/native/AgentServices.swift" "$root/native/tests/BehaviorTests.swift" -o "$temporary/BehaviorTests" "$temporary/BehaviorTests" --fixture "$root/native/tests/fixtures/queue.json" --verify-confirmation-navigation -xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit \ - "$root/native/QueueBrand.swift" "$temporary/AgentQueue.swift" "$root/native/AgentIntegration.swift" "$root/native/AgentUpdates.swift" "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" "$root/native/tests/UpdateTests.swift" -o "$temporary/UpdateTests" +xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit -framework ServiceManagement \ + "$root/native/QueueBrand.swift" "$temporary/AgentQueue.swift" "$root/native/AgentIntegration.swift" "$root/native/AgentUpdates.swift" "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" "$root/native/AgentServices.swift" "$root/native/tests/UpdateTests.swift" -o "$temporary/UpdateTests" "$temporary/UpdateTests" -xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit \ - "$root/native/QueueBrand.swift" "$temporary/AgentQueue.swift" "$root/native/AgentIntegration.swift" "$root/native/AgentUpdates.swift" "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" "$root/native/tests/LearningTests.swift" -o "$temporary/LearningTests" +xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit -framework ServiceManagement \ + "$root/native/QueueBrand.swift" "$temporary/AgentQueue.swift" "$root/native/AgentIntegration.swift" "$root/native/AgentUpdates.swift" "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" "$root/native/AgentServices.swift" "$root/native/tests/LearningTests.swift" -o "$temporary/LearningTests" "$temporary/LearningTests" -xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit \ - "$root/native/QueueBrand.swift" "$temporary/AgentQueue.swift" "$root/native/AgentIntegration.swift" "$root/native/AgentUpdates.swift" "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" "$root/native/tests/TransportTests.swift" -o "$temporary/TransportTests" +xcrun swiftc -warnings-as-errors -swift-version 6 -parse-as-library -Onone -framework SwiftUI -framework AppKit -framework ServiceManagement \ + "$root/native/QueueBrand.swift" "$temporary/AgentQueue.swift" "$root/native/AgentIntegration.swift" "$root/native/AgentUpdates.swift" "$root/native/AgentLearning.swift" "$root/native/AgentBootstrap.swift" "$root/native/AgentServices.swift" "$root/native/tests/TransportTests.swift" -o "$temporary/TransportTests" "$temporary/TransportTests" diff --git a/scripts/test-notarized-installer.sh b/scripts/test-notarized-installer.sh index 8d7f925..3033c89 100755 --- a/scripts/test-notarized-installer.sh +++ b/scripts/test-notarized-installer.sh @@ -28,6 +28,14 @@ copied="$work/installed/Agent Queue.app" xattr -p com.apple.quarantine "$copied" >/dev/null codesign --verify --strict --deep --all-architectures \ -R "=anchor apple generic and certificate leaf[subject.OU] = \"$team\" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists" "$copied" +macos_signing_verification_configure "$team" +macos_app_payload_mode "$copied" +if [[ $MACOS_APP_EMBEDDED == 1 ]]; then + for name in agent-heavy agent-remote; do + macos_verify_code "$MACOS_APP_HELPERS/$name" + done + (cd "$root" && go run -p 2 ./cmd/queue-tool verify-app --app "$copied") +fi macos_validate_ticket "$copied" syspolicy_check distribution "$copied" --verbose "$root/scripts/macos-signing.sh" verify-dmg "$image" "$team" From e345a9dc9897188fbe1eb6bbe294ac2c5fa4a42a Mon Sep 17 00:00:00 2001 From: Marc Went Date: Mon, 26 Oct 2026 21:35:49 +0100 Subject: [PATCH 2/2] docs(services): clarify integrated setup and release flow Signed-off-by: Marc Went --- docs/apple-signing.md | 6 +++--- docs/handoffs/bundle-owned-services.md | 8 +++++++- docs/upgrade-recovery.md | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/apple-signing.md b/docs/apple-signing.md index 12b92be..9990e21 100644 --- a/docs/apple-signing.md +++ b/docs/apple-signing.md @@ -33,11 +33,11 @@ This is a custom installer that copies another app and standalone helpers. Apple explicitly requires payload notarization before assembling such an installer. Our additional DMG distribution also receives its own ticket: -1. Sign the two Go helpers in both the outer payload and the menu app's embedded setup payload, then sign the menu app. Submit the complete payload ZIP. +1. Sign the two Go helpers in both the outer controller payload and the menu app's `Contents/MacOS`, then sign the menu app. Submit the complete payload ZIP. Managed service definitions remain in the app's `Contents/Library/LaunchAgents` and use `BundleProgram`. 2. Require acceptance, fetch the log, then staple and validate the menu app. 3. Sign the outer installer, submit its ZIP, require acceptance, and staple it. 4. Recreate the distribution ZIP with the stapled installer inside it. -5. Copy the stapled, self-contained menu app into a DMG with an Applications shortcut, sign the DMG, notarize it, and staple it. Retained older builds without an embedded setup payload keep their original installer DMG layout. +5. Package the stapled explicit installer in the managed DMG, sign the DMG, notarize it, and staple it. Retained historical drag-to-Applications images keep their original layout and verification path. 6. Generate final checksums and Homebrew metadata after all byte changes. Hosted Apple submissions use `Agent-Job-Queue-Payload-VERSION-build-N-SHA-universal.zip`, @@ -56,7 +56,7 @@ first launch of the complete installed product remains a separate test. These requirements come from Apple's [custom notarization workflow](https://developer.apple.com/documentation/security/customizing-the-notarization-workflow). -The menu app carries its own source payload for first-launch setup after a Finder copy. Its helpers are signed before sealing the app; setup never modifies the installed bundle. The ZIP remains a custom installer for existing updater and Homebrew consumers. The DMG contains the same app, copied with its signature, ticket and quarantine preserved. +The menu app carries support metadata and documents without duplicate helper binaries. Explicit setup can use its canonical bundled helpers; ordinary first launch only checks installation and approval state. Helpers are signed before sealing the app, and in-place setup never modifies the installed bundle. Both managed ZIP and DMG deliver the explicit installer, preserving signatures, tickets and quarantine. Homebrew delivers that installer and command links; runtime installation occurs explicitly outside its hook sandbox. The payload app is stapled before signing its parent because it lives under the installer's sealed resources. Production installation copies the app with diff --git a/docs/handoffs/bundle-owned-services.md b/docs/handoffs/bundle-owned-services.md index 6336132..d21af07 100644 --- a/docs/handoffs/bundle-owned-services.md +++ b/docs/handoffs/bundle-owned-services.md @@ -6,6 +6,12 @@ The user authorized local implementation of explicit installation, read-only ord Original incident evidence and the build-58/build-94 comparison are recorded in the [SentinelOne investigation](sentinelone-detection-investigation.md). +## PR integration and release authorization + +The user subsequently authorized pushing this implementation, resolving conflicts, getting [PR 41](https://github.com/Dunky13/agent-job-queue/pull/41) green, merging it and completing the signed release. Integration preserves main commit `5ab7db8` and its approved-repair and unattended-release changes alongside explicit-only setup. Integrated validation is running through queue job 2161 and the PR's required Apple Silicon/Intel CI matrices. The completed local evidence below records the earlier implementation snapshot; it does not certify the integrated or published artifact. + +Published build 118 uses source `5ab7db8`, which does not contain this PR's native Service Management adapter or bundle-owned service packaging. Its reported SentinelOne alert therefore does not qualify the changed implementation. The authoritative detection and a protected retest of the eventual signed candidate are still required to establish SentinelOne acceptance. + Requirements carried into implementation: - Installation and upgrades perform setup explicitly. Opening an incomplete app shows instructions without rewriting files or automatically retrying recovery. @@ -60,7 +66,7 @@ The private packaged acceptance test exposed a shutdown race after the review ro ## Validation and remaining qualification -Local implementation and validation are complete. Both permitted review rounds have finished, and their findings are fixed and regression-tested. No commit, push, release, Apple submission, real service registration or affected-Mac repair was performed. Completed evidence: +At the initial local handoff, implementation and validation were complete. Both permitted review rounds had finished, and their findings were fixed and regression-tested. No commit, push, release, Apple submission, real service registration or affected-Mac repair had been performed at that checkpoint. Subsequent PR/release work is described above. Completed evidence for that local snapshot: | Boundary | Evidence | Result | | --- | --- | --- | diff --git a/docs/upgrade-recovery.md b/docs/upgrade-recovery.md index 8cbf71e..9d7b23f 100644 --- a/docs/upgrade-recovery.md +++ b/docs/upgrade-recovery.md @@ -4,7 +4,7 @@ Normal app launch checks installation and background-service authorization witho ## Approve repair once -Choose **Repair and continue** to approve the verified repairs needed for startup in one step. Setup restores missing standard service files, runs verified cleanup for completed jobs, starts a verified stopped service, and disables phone access when migration requires it. Credentials and trusted certificates are preserved. Phone access remains off until enabled again in settings. Lost custom startup settings cannot be recovered from missing files. +In explicit setup, choose **Repair and continue** to approve the verified repairs in one step. Setup restores missing standard service files, runs verified cleanup for completed jobs, starts a verified stopped service, and disables phone access when migration requires it. Ordinary startup cannot approve or run these repairs. Credentials and trusted certificates are preserved. Phone access remains off until enabled again in settings. Lost custom startup settings cannot be recovered from missing files. After approval, setup rechecks ownership before each operation, waits for active work, and continues through installation and readback automatically. **Stop waiting** cancels waiting and revokes that approval. An accepted repair is allowed to finish. A failed repair, repeated operation without progress, unknown ownership, custom configuration, or deliberately paused/stashed work stops the automatic flow for review. Approval never authorizes cancelling jobs, resuming held work, or replacing a custom callback.