From e9558e9651d5b11abeef6f247f76713e5a45f28d Mon Sep 17 00:00:00 2001 From: KKKK Date: Mon, 20 Jul 2026 09:47:54 +0800 Subject: [PATCH] persist themes after macOS login --- CHANGELOG.md | 3 + README.md | 16 +++-- docs/ARCHITECTURE.md | 4 +- docs/INSTALL.md | 14 +++- docs/MIGRATION.md | 2 +- docs/RELEASING.md | 10 +-- docs/SAFETY.md | 13 ++-- docs/STATUS.md | 6 +- package.json | 2 +- scripts/catalog.mjs | 2 +- src/cli/main.mjs | 108 ++++++++++++++++++++++++---- src/engine/agent.mjs | 62 +++++++++++++++- src/engine/config.mjs | 16 +++++ src/engine/installer.mjs | 12 +++- src/generated/themes.json | 24 +++---- tests/cli/main.test.mjs | 124 +++++++++++++++++++++++++++++++- tests/docs/public-docs.test.mjs | 9 +-- tests/engine/agent.test.mjs | 101 +++++++++++++++++++++++++- tests/engine/config.test.mjs | 22 ++++++ tests/engine/installer.test.mjs | 17 ++++- tests/gallery/app.test.tsx | 4 +- tests/themes/catalog.test.mjs | 2 +- 22 files changed, 504 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0565348..2678e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes are documented here. The project follows semantic versioning ### Added +- Explicit `--takeover-at-login` installation mode for automatic theme recovery when macOS restores Codex during boot. +- A one-shot 120-second startup handoff that revalidates one official PID, uses normal app quit, confirms complete exit, and fails closed without signals or force quit. +- LaunchServices-based managed startup with real official PID discovery and transient app-discovery retry behavior. - Self-contained per-user runtime installation with an atomic versioned `current` release. - User LaunchAgent commands: `install-agent`, `upgrade-agent`, `switch`, `pause`, `resume`, and `uninstall-agent`. - Portable `release:check` command and CI gate for tests, themes, types, build, production dependency audit, public-file hygiene, and diff whitespace. diff --git a/README.md b/README.md index 80825c7..2f53209 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Every theme below is a complete local package under `themes/`. The left image is | Satoru Gojo theme background | Satoru Gojo marketing preview | ```bash -./bin/awesome-codex-themes install-agent satoru-gojo +./bin/awesome-codex-themes install-agent satoru-gojo --takeover-at-login ``` ### 02. Zaun Workshop @@ -186,11 +186,13 @@ cd Awesome-codex-themes Install the Featured theme as the persistent selection: ```bash -./bin/awesome-codex-themes install-agent satoru-gojo +./bin/awesome-codex-themes install-agent satoru-gojo --takeover-at-login ./bin/awesome-codex-themes status ``` -If Codex is already open without this project's managed CDP endpoint, the agent reports `restart-required` and does not close it. Save your work, quit Codex yourself, and let the installed agent start the verified official app. See [Installation and recovery](docs/INSTALL.md) before enabling persistence. +`--takeover-at-login` is the explicit opt-in that keeps the theme present after a Mac reboot even when macOS restores an ordinary Codex process first. During the first 120 seconds after boot, the agent may request one normal quit of the single verified official process, confirm that exact process is gone, then reopen it through LaunchServices with the saved profile and managed local endpoint. Outside that window, without the flag, with multiple instances, or if identity/exit verification fails, the agent leaves Codex running and reports `restart-required`. + +If Codex is already open during normal work, save your work and quit it yourself before the first managed launch. See [Installation and recovery](docs/INSTALL.md) before enabling persistence. **Best-effort injection is attempted on every numeric Codex Desktop version. Highly compatible and live-verified: `26.707.*` and `26.715.*`.** Other versions are attempted, but their component layout is not guaranteed. @@ -222,7 +224,7 @@ After an official Codex update, the agent attempts the shared adapter again. If ## Current status -Version `0.4.2` includes the engine, twelve complete local theme packages, self-contained user installation, LaunchAgent persistence, conflict diagnostics, safe restore, and a static Gallery. +Version `0.4.3` includes the engine, twelve complete local theme packages, self-contained user installation, opt-in bounded login takeover, LaunchAgent persistence, conflict diagnostics, safe restore, and a static Gallery. | Capability | State | | --- | --- | @@ -232,7 +234,7 @@ Version `0.4.2` includes the engine, twelve complete local theme packages, self- | Loopback-only apply, idempotent reapply, and owned-state removal | Implemented and prototype verified | | Install, upgrade, switch, pause, resume, status, and uninstall | Integration verified on `26.715.21425` | | Renderer reload and full app relaunch recovery | Integration verified on `26.715.21425` | -| Physical macOS reboot | Pending direct exercise | +| Opt-in macOS login takeover | Implemented and automated; physical reboot still pending direct exercise | | Searchable static Gallery | Implemented; deployment is separate from the repository build | ## Commands @@ -242,7 +244,7 @@ Version `0.4.2` includes the engine, twelve complete local theme packages, self- | `list` | Validate and list complete runnable themes. | | `doctor` | Inspect the signed app, exact running PIDs, and known injector conflicts without mutation. | | `start ` | Start one session-scoped managed Codex launch. | -| `install-agent ` | Install the versioned runtime and enable persistent management. | +| `install-agent [--takeover-at-login]` | Install persistence; the optional flag explicitly permits one bounded login-time handoff after reboot. | | `upgrade-agent` | Upgrade the installed runtime while preserving theme and enabled/paused state. | | `switch ` | Validate and select another installed theme. | | `pause` | Disable persistent management and remove project-owned live styling. | @@ -260,7 +262,7 @@ Version `0.4.2` includes the engine, twelve complete local theme packages, self- - Selects only the expected main `app://-/index.html` renderer. - Applies one namespaced style element and one optional inert, pointer-transparent decorative node. - Never reads conversations, account tokens, API keys, model settings, or unrelated user data. -- Never patches `app.asar`, modifies the signed app bundle, uses `--user-data-dir`, or silently terminates an active Codex process. +- Never patches `app.asar`, modifies the signed app bundle, or uses `--user-data-dir`. Process handoff is disabled by default and is allowed only after explicit `--takeover-at-login` consent, for one verified official PID inside the bounded startup window. - `pause`, `restore`, and `uninstall-agent` remove only project-owned runtime state. Read [SECURITY.md](SECURITY.md), [docs/SAFETY.md](docs/SAFETY.md), and [docs/MIGRATION.md](docs/MIGRATION.md) before replacing another injector. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cbff617..d21b20a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,7 +39,7 @@ The public CLI keeps `_watch` and `_agent` internal. The shell entrypoint finds ```text Git clone (mutable source) → validated staging copy - → ~/Library/Application Support/AwesomeCodexThemes/releases/0.4.2/ + → ~/Library/Application Support/AwesomeCodexThemes/releases/0.4.3/ → atomic current symlink → ~/Library/LaunchAgents/io.github.awesome-codex-themes.agent.plist → current/bin/awesome-codex-themes _agent @@ -47,7 +47,7 @@ Git clone (mutable source) The service never points at the clone. Configuration and state remain outside immutable release directories, use owner-only permissions, and are written atomically. Reinstallation boots out only this project's label, updates the installed plist/current release, bootstraps, and kickstarts it. Uninstall removes only project-owned paths. -An enabled agent owns the CDP-enabled launch lifecycle. If an ordinary Dock launch is already running, it reports `restart-required` rather than terminating or relaunching it. This is the unavoidable open-source CDP boundary: the project can persistently inject only into an official process that was started with its verified loopback debugging endpoint. +An enabled agent owns the CDP-enabled launch lifecycle. If an ordinary Dock launch is already running, it normally reports `restart-required` rather than touching it. Users who explicitly install with `--takeover-at-login` permit one narrowly bounded exception: during the first 120 seconds after boot, a single revalidated official PID may receive a normal macOS quit request, and only a confirmed complete exit is followed by a LaunchServices reopen. Multiple processes, changed identity, timeout, or any later session fail closed without force termination. This handles the macOS restore race while preserving the open-source CDP boundary. ## Theme catalog and static gallery diff --git a/docs/INSTALL.md b/docs/INSTALL.md index d781bb5..a2fdd48 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -27,15 +27,23 @@ It does not edit `ChatGPT.app`, `Codex.app`, Codex conversations, settings, cred ## Install Satoru Gojo ```bash -./bin/awesome-codex-themes install-agent satoru-gojo +./bin/awesome-codex-themes install-agent satoru-gojo --takeover-at-login ./bin/awesome-codex-themes status ``` The installed configuration selects `satoru-gojo`, enables persistence, and starts the user LaunchAgent. The agent uses a dynamic literal-loopback CDP port and does not pass `--user-data-dir`. +The optional `--takeover-at-login` flag is explicit consent for a single bounded handoff when macOS restores Codex before the agent can open its managed endpoint. It is the mode to use when the theme should return automatically after reboot. Without the flag, unmanaged Codex processes are always left untouched. + +## Reboot and login handoff + +The handoff is permitted only inside a 120-second startup window and only when exactly one official Codex process is present. The agent revalidates the signed application and exact PID, sends the normal macOS quit request, waits until that PID is gone with no replacement process, then opens the same official app through LaunchServices with a dynamic `127.0.0.1` endpoint. It does not use another `--user-data-dir`, so the existing Codex profile remains in place. + +If the startup window has expired, more than one official process exists, the PID changes, or normal quit cannot be confirmed, the agent fails closed to `restart-required`. It does not escalate to `kill`, `kill -9`, or broad process matching. + ## First transition -If Codex is already running without a trusted endpoint, status becomes `restart-required`. This is deliberate: the agent never terminates an active session. +If Codex is already running without a trusted endpoint outside the explicitly approved startup handoff, status becomes `restart-required`. This protects an active work session. 1. Save any active work. 2. Quit the official Codex app yourself. @@ -110,6 +118,6 @@ If `launchctl bootout` reports that the service is not loaded, continue with the | `idle` | Agent is installed but has not completed a managed launch. | Wait briefly, then run `status`. | | `starting` | A verified managed app launch is in progress. | Wait; do not start another app copy. | | `active` | The selected theme is verified on the managed renderer. | None. | -| `restart-required` | Codex is running without the managed endpoint. | Save work and quit it yourself. | +| `restart-required` | Codex is running without the managed endpoint, or a safe login handoff was not allowed/confirmed. | Save work and quit it yourself. | | `paused` | Persistence is disabled and owned live styling has been removed when reachable. | Run `resume` when desired. | | `error` | A stable error code was recorded. | Run `doctor`, inspect logs, and use manual recovery if necessary. | diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index bc27488..7c04eaa 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -16,7 +16,7 @@ The read-only report recognizes known legacy LaunchAgents, legacy scripts, and C 3. Confirm its LaunchAgent is unloaded and its owned renderer style is absent. 4. Quit Codex yourself if it is still running with the old endpoint. 5. Run `doctor` again. -6. Install this project with `install-agent satoru-gojo`. +6. Install this project with `install-agent satoru-gojo --takeover-at-login` when reboot persistence is desired. 7. Check `status` and complete the first transition described in [INSTALL.md](INSTALL.md). Do not delete an unfamiliar plist or send signals to an unverified PID. A legacy injector can contain unrelated user configuration, and this project deliberately does not claim ownership of it. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 743c592..d405ab3 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -25,14 +25,14 @@ The portable gate runs the full test/theme/type/build suite, the production depe Use an explicitly approved test session on a live-verified official Codex version. Never terminate an active user session to satisfy this checklist. 1. Record `doctor` output and resolve any legacy injector conflict through that injector's documented restore path. -2. Install with `install-agent satoru-gojo` while Codex is already open and confirm `restart-required` without automatic termination. +2. Install without the takeover flag while Codex is already open and confirm `restart-required` without automatic termination. 3. Quit Codex explicitly and confirm the agent starts the verified official app on a dynamic literal-loopback port. 4. Confirm `active`, the existing Codex profile, and the Satoru Gojo presentation. 5. Reload the renderer and confirm idempotent reapply without duplicate owned nodes or styles. 6. Exercise keyboard focus, menus, Composer, code/diff surfaces, reduced motion, and owned-state restore. 7. Run `pause`, confirm owned styling is removed, and confirm Codex can remain closed. 8. Run `resume`, close the managed app, and confirm the agent starts a new managed process with the saved theme. -9. Log out/in or reboot the approved test Mac and confirm LaunchAgent startup. +9. Reinstall with `install-agent satoru-gojo --takeover-at-login`, reboot the approved test Mac, and confirm the bounded handoff or direct managed startup restores the theme. 10. Confirm an unverified numeric-version fixture attempts `codex-best-effort`; if verification fails, confirm `pause` or `restore` returns the official UI. 11. Run `uninstall-agent`, then verify the plist, installed support directory, owned renderer state, and service are gone while the official Codex profile remains intact. 12. Exercise the manual `launchctl bootout` recovery from [INSTALL.md](INSTALL.md). @@ -52,15 +52,15 @@ Before approval is executed, verify: - the repository description, topics, default branch, and security-advisory link are correct; - the owner understands that forks, caches, and downloaded release assets cannot be recalled reliably after publication. -## 5. Main and `v0.4.2` +## 5. Main and `v0.4.3` After the source, lifecycle, and visibility gates pass: 1. Land the reviewed release commit on `main` without overwriting unrelated local work. 2. Rerun `pnpm check` from the exact `main` commit. 3. Push `main` and confirm GitHub Actions succeeds on that SHA. -4. Create the annotated `v0.4.2` tag from the verified commit. +4. Create the annotated `v0.4.3` tag from the verified commit. 5. Publish a GitHub Release that links to [INSTALL.md](INSTALL.md), [MIGRATION.md](MIGRATION.md), [SAFETY.md](SAFETY.md), and the exact live-verified Codex ranges while explaining the best-effort fallback. -6. Confirm a fresh public clone can run `doctor`, `list`, an isolated `install-agent satoru-gojo` flow, and an `upgrade-agent` flow that preserves a paused configuration. +6. Confirm a fresh public clone can run `doctor`, `list`, an isolated `install-agent satoru-gojo --takeover-at-login` flow, and an `upgrade-agent` flow that preserves paused and takeover configuration. Do not describe login/reboot persistence as publicly verified until both the local lifecycle record and the CI/release SHA are available. diff --git a/docs/SAFETY.md b/docs/SAFETY.md index 6d8a2fd..1ded89f 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -22,7 +22,7 @@ It does not trust: 1. Discover the official `ChatGPT.app` or legacy `Codex.app` bundle. 2. Verify code signature integrity, bundle ID `com.openai.codex`, Team ID `2DC432GLL2`, architecture, and bundled Node version. 3. Refuse a managed launch if an exact official app process is already running without this project's trusted endpoint. -4. Select an unused non-privileged port and launch the inspected executable with `--remote-debugging-address=127.0.0.1`. +4. Select an unused non-privileged port and launch the inspected app through macOS LaunchServices with `--remote-debugging-address=127.0.0.1`. 5. Verify the listener address and confirm its PID belongs to the inspected official app. 6. Query `/json/list` only on literal loopback and accept only the expected main `app://-/index.html` renderer. 7. Load a local theme and validate its manifest, paths, sizes, CSS, and live-verified Codex version metadata. @@ -38,9 +38,11 @@ It does not trust: 3. Generate a user LaunchAgent that calls only the stable installed launcher. 4. Boot out only this project's existing label, then bootstrap and kickstart the replacement. 5. If Codex is absent, launch the verified official executable with a dynamic `127.0.0.1` CDP port and no alternate data directory. -6. If Codex is already running unmanaged, record `restart-required` and leave it untouched. -7. Reapply idempotently while the verified managed process and endpoint remain valid. -8. After a Codex version change, attempt the verified or best-effort adapter and report a normal typed error if renderer verification fails. +6. If Codex is already running unmanaged, record `restart-required` and leave it untouched by default. +7. Only after explicit `--takeover-at-login` consent, within 120 seconds of boot, and with exactly one revalidated official PID, request one normal macOS quit and confirm complete exit before a managed relaunch. +8. Never escalate a login handoff to a signal, force quit, wildcard process match, or second attempt; any ambiguity fails closed to `restart-required`. +9. Reapply idempotently while the verified managed process and endpoint remain valid. +10. After a Codex version change, attempt the verified or best-effort adapter and report a normal typed error if renderer verification fails. ## Restore sequence @@ -65,8 +67,9 @@ For an installed persistent agent, `pause` and no-port `restore` first set `enab - An enabled persistent agent may relaunch Codex after it exits; use `pause` before intentionally keeping the app closed. - An official app update may receive a best-effort theme immediately; no claim of visual compatibility is made until that version is recorded as verified. - Malformed state is reported and not silently discarded. +- Temporary app-discovery failures during login are recorded and retried by the persistent loop instead of disabling the service. - Concurrent third-party theme injectors are unsupported because their high-specificity rules can override either theme. Restore or disable the other theme before evaluating Awesome Codex Themes; this engine never deletes unrelated style elements. ## Non-goals -The engine does not provide a security boundary against a malicious local administrator or a compromised official application. It does not secure CDP for unrelated software, sign a desktop distribution, intercept an ordinary Dock launch, or guarantee visual compatibility on unverified Codex versions. +The engine does not provide a security boundary against a malicious local administrator or a compromised official application. It does not secure CDP for unrelated software, sign a desktop distribution, permanently intercept every ordinary Dock launch, or guarantee visual compatibility on unverified Codex versions. diff --git a/docs/STATUS.md b/docs/STATUS.md index 3571501..205a9eb 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Implementation status -Last verified: 2026-07-17 (Asia/Shanghai) +Last verified: 2026-07-20 (Asia/Shanghai) ## State definitions @@ -21,7 +21,7 @@ Last verified: 2026-07-17 (Asia/Shanghai) | Self-contained versioned installer | Implemented | Isolated filesystem tests cover staging, atomic activation, configuration, permissions, replacement, and cleanup. | | User LaunchAgent | Integration verified | The legacy plist was backed up and replaced; install, bootout/bootstrap, kickstart, pause/resume, upgrade, and uninstall ran through the real user service boundary. `status` also requires the service to be loaded before it reports `active`. | | Full app exit/relaunch recovery | Integration verified | An unmanaged process transitioned through `restart-required` and was replaced by a verified CDP-managed official process without a second profile. | -| Login service bootstrap | Integration verified; physical reboot pending | `RunAtLoad`/`KeepAlive` plus a real bootout/bootstrap cycle were verified. A physical logout or reboot was not directly observed. | +| Login service bootstrap | Implemented and integration verified; physical reboot pending | `RunAtLoad`/`KeepAlive`, LaunchServices PID acquisition, and an opt-in one-shot 120-second login handoff have automated coverage. A real bootout/bootstrap and managed app restart were verified; a full physical reboot after this implementation is still pending. | | Best-effort version fallback | Implemented | Automated coverage confirms every valid numeric app version selects the shared `codex-best-effort` adapter instead of being rejected. Live visual verification on an unverified future build remains pending. | | Satoru Gojo (native Japanese name: 五条 悟) | Featured complete package, experimental compatibility | Version `1.2.0` is live-verified on 26.707/26.715 with current assets, owned-state behavior, and persistent lifecycle recovery. | | Twelve-theme collection | Implemented and current-version verified | Satoru Gojo is Featured. Eleven additional Schema 2 packages include complete local backgrounds, previews, CSS, metadata, and component assets; all eleven passed the recorded 26.715 workspace, right-panel, home, plugins, scheduled, sites, pull-request, and narrow-window matrix. Compatibility remains experimental outside the recorded builds. | @@ -42,7 +42,7 @@ Last verified: 2026-07-17 (Asia/Shanghai) The active renderer was checked for owned style/chrome markers, theme variables, workspace classification, idempotent apply, renderer reload reapply, pointer-transparent decoration, and complete owned-state removal. Private screenshots with active workspace content and internal visual iteration labels remain local development evidence and are not public release assets. See the sanitized [26.715 lifecycle audit](audits/2026-07-17-satoru-gojo-26.715-lifecycle-audit.md). -The tested release candidate is installed in the immutable local `0.4.2` runtime. The active service reports `active`; `new-world-studio` was verified with one owned style node, an open right-panel marker, and a dedicated panel background layer. The final handoff restores Satoru Gojo as the selected theme. +The public source candidate is `0.4.3`. The current local service is separately verified as `active` on Codex `26.715.31925` with a literal-loopback endpoint and one owned `nocturne-lite` style; installing the final `0.4.3` local derivative and exercising a physical reboot remain separate gates. The legacy injector plist and script were backed up outside the repository before migration. The old plist is no longer present in `~/Library/LaunchAgents`; its source script was not deleted. diff --git a/package.json b/package.json index 0635ed7..e0374bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "awesome-codex-themes", - "version": "0.4.2", + "version": "0.4.3", "packageManager": "pnpm@10.32.1", "private": false, "type": "module", diff --git a/scripts/catalog.mjs b/scripts/catalog.mjs index c3fe82c..090ac46 100644 --- a/scripts/catalog.mjs +++ b/scripts/catalog.mjs @@ -30,7 +30,7 @@ export async function buildThemeCatalog(themesRoot) { palette: manifest.palette, ...(manifest.experience ? { experience: manifest.experience } : {}), preview: `/theme-assets/${manifest.slug}/${basename(manifest.files.preview)}`, - command: `./bin/awesome-codex-themes install-agent ${manifest.slug}`, + command: `./bin/awesome-codex-themes install-agent ${manifest.slug} --takeover-at-login`, }); } diff --git a/src/cli/main.mjs b/src/cli/main.mjs index 637cef2..0080eca 100644 --- a/src/cli/main.mjs +++ b/src/cli/main.mjs @@ -2,7 +2,7 @@ import { execFile } from 'node:child_process'; import { realpathSync } from 'node:fs'; import { readFile, readdir, rm } from 'node:fs/promises'; import { createServer } from 'node:net'; -import { homedir } from 'node:os'; +import { homedir, uptime as systemUptime } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; @@ -57,7 +57,7 @@ Usage: awesome-codex-themes list awesome-codex-themes doctor awesome-codex-themes start - awesome-codex-themes install-agent + awesome-codex-themes install-agent [--takeover-at-login] awesome-codex-themes upgrade-agent awesome-codex-themes switch awesome-codex-themes pause @@ -67,7 +67,7 @@ Usage: awesome-codex-themes restore [--port ] awesome-codex-themes uninstall-agent -The engine only connects to CDP on 127.0.0.1 and never terminates the official app.`; +The engine only connects to CDP on 127.0.0.1. Login takeover is bounded and requires explicit opt-in.`; export class CliError extends Error { constructor(code, message, options = {}) { @@ -165,9 +165,15 @@ export async function runCli(argv, dependencies, io = { out: console.log, error: if (command === 'install-agent') { const theme = requireTheme(args); - rejectExtraArgs(args, 1, 'install-agent'); - const result = await dependencies.installAgent(theme); - io.out(`installed persistent theme ${result.theme} at ${result.supportRoot}`); + const takeoverAtLogin = args.length === 2 && args[1] === '--takeover-at-login'; + if (args.length > 2 || (args.length === 2 && !takeoverAtLogin)) { + cliFail('CLI_ARGUMENT_INVALID', 'install-agent accepts only the optional --takeover-at-login flag.'); + } + const result = await dependencies.installAgent(theme, { takeoverAtLogin }); + io.out( + `installed persistent theme ${result.theme} at ${result.supportRoot}` + + (takeoverAtLogin ? '; login takeover enabled for the bounded startup window' : ''), + ); return 0; } @@ -323,14 +329,69 @@ async function observeInjector(pid, expected) { } } -function launchOfficialApp(app, port) { - const child = spawn( - app.executable, - [`--remote-debugging-address=127.0.0.1`, `--remote-debugging-port=${port}`], - { detached: true, stdio: 'ignore' }, +export async function launchOfficialApp( + app, + port, + { + runOpen = (command, args) => execFileAsync(command, args, { encoding: 'utf8' }), + listPids = listOfficialAppPids, + delay = (milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)), + attempts = 40, + } = {}, +) { + try { + await runOpen('/usr/bin/open', [ + '-na', + app.appPath, + '--args', + '--remote-debugging-address=127.0.0.1', + `--remote-debugging-port=${port}`, + ]); + } catch { + return { pid: null }; + } + + for (let attempt = 0; attempt < attempts; attempt += 1) { + const pids = await listPids(app); + if (pids.length === 1) return { pid: pids[0] }; + if (pids.length > 1) return { pid: null }; + await delay(100); + } + return { pid: null }; +} + +export async function requestOfficialAppQuit( + app, + expectedPid, + { listPids = listOfficialAppPids, runCommand = execFileAsync } = {}, +) { + const pids = await listPids(app); + if (pids.length !== 1 || pids[0] !== expectedPid) { + cliFail('APP_PROCESS_CHANGED', 'The official app process changed before login takeover; it was left running.'); + } + await runCommand( + '/usr/bin/osascript', + ['-e', `tell application id "${app.bundleId}" to quit`], + { encoding: 'utf8' }, ); - child.unref(); - return child; +} + +export async function waitForOfficialAppExit( + app, + expectedPid, + { + listPids = listOfficialAppPids, + delay = (milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)), + attempts = 100, + } = {}, +) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + const pids = await listPids(app); + if (pids.length === 0) return true; + if (!pids.includes(expectedPid)) return false; + await delay(100); + } + return false; } function spawnWatcher({ themeSlug, port, appPid, app }) { @@ -371,7 +432,20 @@ export async function terminateProcess( cliFail('INJECTOR_STOP_TIMEOUT', `Watcher ${pid} did not exit after SIGTERM; live theme removal was not claimed.`); } +export function createStartupTakeoverGate({ uptime = systemUptime } = {}) { + let startupTakeoverAttempted = false; + + return function startupTakeoverAllowed(windowSeconds) { + if (!Number.isInteger(windowSeconds) || windowSeconds < 30 || windowSeconds > 300) return false; + if (startupTakeoverAttempted || uptime() > windowSeconds) return false; + startupTakeoverAttempted = true; + return true; + }; +} + export function createDefaultDependencies() { + const startupTakeoverAllowed = createStartupTakeoverGate(); + async function pauseInstalledTheme() { const paths = await persistentPaths(); return pausePersistentInstallation( @@ -447,13 +521,14 @@ export function createDefaultDependencies() { ); }, - async installAgent(themeSlug) { + async installAgent(themeSlug, { takeoverAtLogin = false } = {}) { await discoverOfficialApp(); return installPersistentAgent({ sourceRoot: projectRoot, home: homedir(), version: await projectVersion(), themeSlug, + takeoverAtLogin, }); }, @@ -468,6 +543,8 @@ export function createDefaultDependencies() { version, themeSlug: config.themeSlug, enabled: config.enabled, + takeoverAtLogin: config.takeoverAtLogin, + startupTakeoverWindowSeconds: config.startupTakeoverWindowSeconds, }); return { ...result, upgraded: true, enabled: config.enabled }; }, @@ -667,6 +744,9 @@ export function createDefaultDependencies() { launchApp: launchOfficialApp, waitForRenderer, assertPortOwner: assertOfficialPortOwner, + startupTakeoverAllowed, + requestAppQuit: requestOfficialAppQuit, + waitForAppExit: waitForOfficialAppExit, applyTheme: ({ themeSlug, port, app }) => applyThemeAtPort({ themesRoot, themeSlug, port, appVersion: app.version }), removeTheme: ({ port }) => removeThemeAtPort({ port }), diff --git a/src/engine/agent.mjs b/src/engine/agent.mjs index 81b7edb..2306087 100644 --- a/src/engine/agent.mjs +++ b/src/engine/agent.mjs @@ -34,10 +34,28 @@ export async function agentStep({ config, runtime }, dependencies) { assertPortOwner, applyTheme, removeTheme, + startupTakeoverAllowed, + requestAppQuit, + waitForAppExit, now = () => new Date().toISOString(), } = dependencies; - const app = await discoverApp(); + let app; + try { + app = await discoverApp(); + } catch (error) { + return { + runtime, + state: observedState( + { + status: 'error', + themeSlug: config.themeSlug, + errorCode: error?.code ?? 'APP_DISCOVERY_FAILED', + }, + now, + ), + }; + } const runningPids = await listPids(app); if (!config.enabled) { @@ -68,6 +86,44 @@ export async function agentStep({ config, runtime }, dependencies) { let managed = runtime && runningPids.includes(runtime.appPid) ? runtime : null; if (!managed && runningPids.length > 0) { + if ( + config.launchAtLogin && + config.takeoverAtLogin && + runningPids.length === 1 && + typeof startupTakeoverAllowed === 'function' && + typeof requestAppQuit === 'function' && + typeof waitForAppExit === 'function' && + startupTakeoverAllowed(config.startupTakeoverWindowSeconds) + ) { + try { + const appPid = runningPids[0]; + await requestAppQuit(app, appPid); + if (await waitForAppExit(app, appPid)) { + return { + runtime: null, + state: observedState( + { status: 'starting', themeSlug: config.themeSlug, appVersion: app.version }, + now, + ), + }; + } + } catch { + // The opt-in takeover is one-shot and fails closed to restart-required. + } + return { + runtime: null, + state: observedState( + { + status: 'restart-required', + themeSlug: config.themeSlug, + appPid: runningPids[0], + appVersion: app.version, + errorCode: 'APP_STARTUP_TAKEOVER_FAILED', + }, + now, + ), + }; + } return { runtime: null, state: observedState( @@ -76,7 +132,9 @@ export async function agentStep({ config, runtime }, dependencies) { themeSlug: config.themeSlug, appPid: runningPids[0], appVersion: app.version, - errorCode: 'APP_RUNNING_WITHOUT_MANAGED_CDP', + errorCode: runningPids.length > 1 + ? 'APP_MULTIPLE_INSTANCES' + : 'APP_RUNNING_WITHOUT_MANAGED_CDP', }, now, ), diff --git a/src/engine/config.mjs b/src/engine/config.mjs index a8001b9..b158165 100644 --- a/src/engine/config.mjs +++ b/src/engine/config.mjs @@ -22,6 +22,20 @@ export function validateAgentConfig(input) { if (typeof input.enabled !== 'boolean' || typeof input.launchAtLogin !== 'boolean') { fail('CONFIG_FIELD_INVALID', 'enabled and launchAtLogin must be booleans.'); } + if (input.takeoverAtLogin !== undefined && typeof input.takeoverAtLogin !== 'boolean') { + fail('CONFIG_FIELD_INVALID', 'takeoverAtLogin must be a boolean.'); + } + if (input.takeoverAtLogin === true && input.launchAtLogin !== true) { + fail('CONFIG_FIELD_INVALID', 'takeoverAtLogin requires launchAtLogin.'); + } + const startupTakeoverWindowSeconds = input.startupTakeoverWindowSeconds ?? 120; + if ( + !Number.isInteger(startupTakeoverWindowSeconds) || + startupTakeoverWindowSeconds < 30 || + startupTakeoverWindowSeconds > 300 + ) { + fail('CONFIG_FIELD_INVALID', 'startupTakeoverWindowSeconds must be an integer from 30 to 300.'); + } if (typeof input.themeSlug !== 'string' || !SLUG_PATTERN.test(input.themeSlug)) { fail('CONFIG_THEME_INVALID', 'themeSlug must use lowercase kebab-case.'); } @@ -30,6 +44,8 @@ export function validateAgentConfig(input) { enabled: input.enabled, themeSlug: input.themeSlug, launchAtLogin: input.launchAtLogin, + takeoverAtLogin: input.takeoverAtLogin ?? false, + startupTakeoverWindowSeconds, }; } diff --git a/src/engine/installer.mjs b/src/engine/installer.mjs index 96e2789..0147a65 100644 --- a/src/engine/installer.mjs +++ b/src/engine/installer.mjs @@ -71,7 +71,15 @@ async function validateRunnableThemes(sourceRoot, selectedTheme) { } export async function installPersistentAgent( - { sourceRoot, home, version, themeSlug, enabled = true }, + { + sourceRoot, + home, + version, + themeSlug, + enabled = true, + takeoverAtLogin = false, + startupTakeoverWindowSeconds = 120, + }, { copy = (source, destination) => cp(source, destination, { recursive: true, force: true }), bootout = bootoutLaunchAgent, @@ -108,6 +116,8 @@ export async function installPersistentAgent( enabled, themeSlug, launchAtLogin: true, + takeoverAtLogin, + startupTakeoverWindowSeconds, }); const plist = buildLaunchAgentPlist({ diff --git a/src/generated/themes.json b/src/generated/themes.json index 77cf2d2..3e8c83f 100644 --- a/src/generated/themes.json +++ b/src/generated/themes.json @@ -79,7 +79,7 @@ "chrome": true }, "preview": "/theme-assets/castle-archive/preview.jpg", - "command": "./bin/awesome-codex-themes install-agent castle-archive" + "command": "./bin/awesome-codex-themes install-agent castle-archive --takeover-at-login" }, { "slug": "foundling-garden", @@ -161,7 +161,7 @@ "chrome": true }, "preview": "/theme-assets/foundling-garden/preview.jpg", - "command": "./bin/awesome-codex-themes install-agent foundling-garden" + "command": "./bin/awesome-codex-themes install-agent foundling-garden --takeover-at-login" }, { "slug": "grand-line", @@ -243,7 +243,7 @@ "chrome": true }, "preview": "/theme-assets/grand-line/preview.jpg", - "command": "./bin/awesome-codex-themes install-agent grand-line" + "command": "./bin/awesome-codex-themes install-agent grand-line --takeover-at-login" }, { "slug": "mordor-runtime", @@ -325,7 +325,7 @@ "chrome": true }, "preview": "/theme-assets/mordor-runtime/preview.jpg", - "command": "./bin/awesome-codex-themes install-agent mordor-runtime" + "command": "./bin/awesome-codex-themes install-agent mordor-runtime --takeover-at-login" }, { "slug": "new-world-studio", @@ -407,7 +407,7 @@ "chrome": true }, "preview": "/theme-assets/new-world-studio/preview.jpg", - "command": "./bin/awesome-codex-themes install-agent new-world-studio" + "command": "./bin/awesome-codex-themes install-agent new-world-studio --takeover-at-login" }, { "slug": "night-city", @@ -489,7 +489,7 @@ "chrome": true }, "preview": "/theme-assets/night-city/preview.jpg", - "command": "./bin/awesome-codex-themes install-agent night-city" + "command": "./bin/awesome-codex-themes install-agent night-city --takeover-at-login" }, { "slug": "overworld-realms", @@ -571,7 +571,7 @@ "chrome": true }, "preview": "/theme-assets/overworld-realms/preview.jpg", - "command": "./bin/awesome-codex-themes install-agent overworld-realms" + "command": "./bin/awesome-codex-themes install-agent overworld-realms --takeover-at-login" }, { "slug": "saiyan-ukiyoe", @@ -653,7 +653,7 @@ "chrome": true }, "preview": "/theme-assets/saiyan-ukiyoe/preview.jpg", - "command": "./bin/awesome-codex-themes install-agent saiyan-ukiyoe" + "command": "./bin/awesome-codex-themes install-agent saiyan-ukiyoe --takeover-at-login" }, { "slug": "satoru-gojo", @@ -740,7 +740,7 @@ "chrome": true }, "preview": "/theme-assets/satoru-gojo/preview.png", - "command": "./bin/awesome-codex-themes install-agent satoru-gojo" + "command": "./bin/awesome-codex-themes install-agent satoru-gojo --takeover-at-login" }, { "slug": "slingshot-lab", @@ -822,7 +822,7 @@ "chrome": true }, "preview": "/theme-assets/slingshot-lab/preview.jpg", - "command": "./bin/awesome-codex-themes install-agent slingshot-lab" + "command": "./bin/awesome-codex-themes install-agent slingshot-lab --takeover-at-login" }, { "slug": "symbiote-sumi-e", @@ -904,7 +904,7 @@ "chrome": true }, "preview": "/theme-assets/symbiote-sumi-e/preview.jpg", - "command": "./bin/awesome-codex-themes install-agent symbiote-sumi-e" + "command": "./bin/awesome-codex-themes install-agent symbiote-sumi-e --takeover-at-login" }, { "slug": "zaun-workshop", @@ -986,6 +986,6 @@ "chrome": true }, "preview": "/theme-assets/zaun-workshop/preview.jpg", - "command": "./bin/awesome-codex-themes install-agent zaun-workshop" + "command": "./bin/awesome-codex-themes install-agent zaun-workshop --takeover-at-login" } ] diff --git a/tests/cli/main.test.mjs b/tests/cli/main.test.mjs index 4387ca5..0a1b5a7 100644 --- a/tests/cli/main.test.mjs +++ b/tests/cli/main.test.mjs @@ -1,6 +1,15 @@ import { describe, expect, test, vi } from 'vitest'; -import { CliError, runCli, terminateProcess, waitForRenderer } from '../../src/cli/main.mjs'; +import { + CliError, + createStartupTakeoverGate, + launchOfficialApp, + requestOfficialAppQuit, + runCli, + terminateProcess, + waitForOfficialAppExit, + waitForRenderer, +} from '../../src/cli/main.mjs'; function createIo() { const stdout = []; @@ -41,7 +50,7 @@ describe('runCli', () => { await expect(runCli(['help'], dependencies(), io)).resolves.toBe(0); expect(stdout.join('\n')).toContain('start '); - expect(stdout.join('\n')).toContain('install-agent '); + expect(stdout.join('\n')).toContain('install-agent [--takeover-at-login]'); expect(stdout.join('\n')).toContain('upgrade-agent'); expect(stdout.join('\n')).toContain('switch '); expect(stdout.join('\n')).toContain('uninstall-agent'); @@ -156,11 +165,33 @@ describe('runCli', () => { await expect(runCli(['install-agent', 'satoru-gojo'], dependencies({ installAgent }), io)).resolves.toBe(0); - expect(installAgent).toHaveBeenCalledWith('satoru-gojo'); + expect(installAgent).toHaveBeenCalledWith('satoru-gojo', { takeoverAtLogin: false }); expect(stdout.join('\n')).toContain('installed persistent theme satoru-gojo'); expect(stdout.join('\n')).toContain('/support'); }); + test('requires explicit opt-in before enabling bounded login takeover', async () => { + const { io, stdout } = createIo(); + const installAgent = vi.fn(dependencies().installAgent); + + await expect( + runCli(['install-agent', 'satoru-gojo', '--takeover-at-login'], dependencies({ installAgent }), io), + ).resolves.toBe(0); + + expect(installAgent).toHaveBeenCalledWith('satoru-gojo', { takeoverAtLogin: true }); + expect(stdout.join('\n')).toContain('login takeover enabled'); + }); + + test('rejects unknown install-agent flags', async () => { + const { io, stderr } = createIo(); + + await expect( + runCli(['install-agent', 'satoru-gojo', '--force'], dependencies(), io), + ).resolves.toBe(1); + + expect(stderr.join('\n')).toContain('[CLI_ARGUMENT_INVALID]'); + }); + test('switches the saved persistent theme without accepting extra arguments', async () => { const { io, stdout } = createIo(); const switchTheme = vi.fn(dependencies().switchTheme); @@ -321,3 +352,90 @@ describe('managed renderer readiness', () => { expect(events).toEqual(['owner', 'owner', 'renderer']); }); }); + +describe('official app launch', () => { + test('uses LaunchServices and resolves the exact real official app PID', async () => { + const runOpen = vi.fn(async () => ({ stdout: '', stderr: '' })); + const listPids = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([123]); + + await expect(launchOfficialApp( + { appPath: '/Applications/ChatGPT.app' }, + 9341, + { runOpen, listPids, delay: async () => {}, attempts: 2 }, + )).resolves.toEqual({ pid: 123 }); + + expect(runOpen).toHaveBeenCalledWith('/usr/bin/open', [ + '-na', + '/Applications/ChatGPT.app', + '--args', + '--remote-debugging-address=127.0.0.1', + '--remote-debugging-port=9341', + ]); + }); + + test('fails closed if LaunchServices creates ambiguous official app processes', async () => { + await expect(launchOfficialApp( + { appPath: '/Applications/ChatGPT.app' }, + 9341, + { + runOpen: async () => ({ stdout: '', stderr: '' }), + listPids: async () => [123, 124], + delay: async () => {}, + attempts: 1, + }, + )).resolves.toEqual({ pid: null }); + }); + + test('requests a normal app quit only while the exact single official PID is unchanged', async () => { + const runCommand = vi.fn(async () => ({ stdout: '', stderr: '' })); + const app = { bundleId: 'com.openai.codex' }; + + await expect(requestOfficialAppQuit(app, 123, { + listPids: async () => [123], + runCommand, + })).resolves.toBeUndefined(); + + expect(runCommand).toHaveBeenCalledWith('/usr/bin/osascript', [ + '-e', + 'tell application id "com.openai.codex" to quit', + ], { encoding: 'utf8' }); + + await expect(requestOfficialAppQuit(app, 123, { + listPids: async () => [124], + runCommand, + })).rejects.toEqual(expect.objectContaining({ code: 'APP_PROCESS_CHANGED' })); + expect(runCommand).toHaveBeenCalledTimes(1); + }); + + test('confirms complete app exit and rejects PID replacement during takeover', async () => { + await expect(waitForOfficialAppExit({}, 123, { + listPids: vi.fn().mockResolvedValueOnce([123]).mockResolvedValueOnce([]), + delay: async () => {}, + attempts: 2, + })).resolves.toBe(true); + + await expect(waitForOfficialAppExit({}, 123, { + listPids: async () => [124], + delay: async () => {}, + attempts: 1, + })).resolves.toBe(false); + }); +}); + +describe('startup takeover gate', () => { + test('allows exactly one attempt inside the configured boot window', () => { + const gate = createStartupTakeoverGate({ uptime: () => 30 }); + + expect(gate(120)).toBe(true); + expect(gate(120)).toBe(false); + }); + + test('rejects attempts outside the boot window and malformed windows', () => { + expect(createStartupTakeoverGate({ uptime: () => 121 })(120)).toBe(false); + expect(createStartupTakeoverGate({ uptime: () => 1 })(0)).toBe(false); + expect(createStartupTakeoverGate({ uptime: () => 1 })(301)).toBe(false); + }); +}); diff --git a/tests/docs/public-docs.test.mjs b/tests/docs/public-docs.test.mjs index 2529b0f..c034265 100644 --- a/tests/docs/public-docs.test.mjs +++ b/tests/docs/public-docs.test.mjs @@ -13,7 +13,7 @@ describe('public English-first documentation', () => { const readme = await text('README.md'); for (const command of [ - 'install-agent satoru-gojo', + 'install-agent satoru-gojo --takeover-at-login', 'upgrade-agent', 'switch satoru-gojo', 'pause', @@ -42,7 +42,8 @@ describe('public English-first documentation', () => { text('docs/RELEASING.md'), ]); - expect(install).toContain('install-agent satoru-gojo'); + expect(install).toContain('install-agent satoru-gojo --takeover-at-login'); + expect(install).toContain('120-second startup window'); expect(install).toContain('restart-required'); expect(install).toContain('launchctl bootout'); expect(migration).toContain('legacy injector'); @@ -52,7 +53,7 @@ describe('public English-first documentation', () => { expect(releasing).toContain('Public visibility gate'); expect(releasing).toContain('pnpm check'); expect(releasing).toContain('macOS lifecycle evidence'); - expect(releasing).toContain('v0.4.2'); + expect(releasing).toContain('v0.4.3'); }); test('publishes only complete runnable theme previews', async () => { @@ -99,6 +100,6 @@ describe('public English-first documentation', () => { test('bumps the platform for the persistent-agent release line', async () => { const packageJson = JSON.parse(await text('package.json')); - expect(packageJson.version).toBe('0.4.2'); + expect(packageJson.version).toBe('0.4.3'); }); }); diff --git a/tests/engine/agent.test.mjs b/tests/engine/agent.test.mjs index 21ea4ef..d62f833 100644 --- a/tests/engine/agent.test.mjs +++ b/tests/engine/agent.test.mjs @@ -3,7 +3,15 @@ import { describe, expect, test, vi } from 'vitest'; import { agentStep, runPersistentAgent } from '../../src/engine/agent.mjs'; function config(overrides = {}) { - return { schemaVersion: 1, enabled: true, themeSlug: 'satoru-gojo', launchAtLogin: true, ...overrides }; + return { + schemaVersion: 1, + enabled: true, + themeSlug: 'satoru-gojo', + launchAtLogin: true, + takeoverAtLogin: false, + startupTakeoverWindowSeconds: 120, + ...overrides, + }; } function app() { @@ -20,6 +28,9 @@ function dependencies(overrides = {}) { assertPortOwner: vi.fn(async () => {}), applyTheme: vi.fn(async ({ themeSlug }) => ({ theme: themeSlug, renderers: 1 })), removeTheme: vi.fn(async () => ({ restored: true })), + startupTakeoverAllowed: vi.fn(() => false), + requestAppQuit: vi.fn(async () => {}), + waitForAppExit: vi.fn(async () => true), now: () => '2026-07-17T00:00:00.000Z', ...overrides, }; @@ -80,6 +91,94 @@ describe('persistent agent state machine', () => { expect(terminate).not.toHaveBeenCalled(); }); + test('takes over one macOS-restored official app once during an explicitly enabled login window', async () => { + const deps = dependencies({ + listPids: vi.fn(async () => [777]), + startupTakeoverAllowed: vi.fn(() => true), + }); + + const result = await agentStep({ + config: config({ takeoverAtLogin: true }), + runtime: null, + }, deps); + + expect(deps.startupTakeoverAllowed).toHaveBeenCalledWith(120); + expect(deps.requestAppQuit).toHaveBeenCalledWith(app(), 777); + expect(deps.waitForAppExit).toHaveBeenCalledWith(app(), 777); + expect(deps.launchApp).not.toHaveBeenCalled(); + expect(result).toEqual({ + runtime: null, + state: expect.objectContaining({ status: 'starting', appPid: null, errorCode: null }), + }); + }); + + test('does not take over outside the bounded startup window', async () => { + const deps = dependencies({ + listPids: vi.fn(async () => [777]), + startupTakeoverAllowed: vi.fn(() => false), + }); + + const result = await agentStep({ + config: config({ takeoverAtLogin: true }), + runtime: null, + }, deps); + + expect(deps.requestAppQuit).not.toHaveBeenCalled(); + expect(result.state).toEqual(expect.objectContaining({ + status: 'restart-required', + errorCode: 'APP_RUNNING_WITHOUT_MANAGED_CDP', + })); + }); + + test('fails closed when startup takeover cannot confirm the exact official PID exited', async () => { + const deps = dependencies({ + listPids: vi.fn(async () => [777]), + startupTakeoverAllowed: vi.fn(() => true), + waitForAppExit: vi.fn(async () => false), + }); + + const result = await agentStep({ + config: config({ takeoverAtLogin: true }), + runtime: null, + }, deps); + + expect(deps.launchApp).not.toHaveBeenCalled(); + expect(result.state).toEqual(expect.objectContaining({ + status: 'restart-required', + appPid: 777, + errorCode: 'APP_STARTUP_TAKEOVER_FAILED', + })); + }); + + test('never takes over when more than one official app process is observed', async () => { + const deps = dependencies({ + listPids: vi.fn(async () => [777, 778]), + startupTakeoverAllowed: vi.fn(() => true), + }); + + const result = await agentStep({ + config: config({ takeoverAtLogin: true }), + runtime: null, + }, deps); + + expect(deps.startupTakeoverAllowed).not.toHaveBeenCalled(); + expect(deps.requestAppQuit).not.toHaveBeenCalled(); + expect(result.state).toEqual(expect.objectContaining({ + status: 'restart-required', + errorCode: 'APP_MULTIPLE_INSTANCES', + })); + }); + + test('keeps retrying after official app discovery is temporarily unavailable at login', async () => { + const missing = Object.assign(new Error('updating'), { code: 'APP_NOT_FOUND' }); + const deps = dependencies({ discoverApp: vi.fn(async () => { throw missing; }) }); + + const result = await agentStep({ config: config(), runtime: null }, deps); + + expect(result.runtime).toBeNull(); + expect(result.state).toEqual(expect.objectContaining({ status: 'error', errorCode: 'APP_NOT_FOUND' })); + }); + test('pauses without launching, removes owned styling, and retains a reachable managed endpoint for resume', async () => { const deps = dependencies({ listPids: vi.fn(async () => [123]) }); const runtime = { appPid: 123, port: 9341, themeSlug: 'satoru-gojo' }; diff --git a/tests/engine/config.test.mjs b/tests/engine/config.test.mjs index 1158aa7..75c79c5 100644 --- a/tests/engine/config.test.mjs +++ b/tests/engine/config.test.mjs @@ -18,6 +18,8 @@ function validConfig(overrides = {}) { enabled: true, themeSlug: 'satoru-gojo', launchAtLogin: true, + takeoverAtLogin: true, + startupTakeoverWindowSeconds: 120, ...overrides, }; } @@ -31,10 +33,30 @@ describe('agent configuration', () => { expect(validateAgentConfig(validConfig())).toEqual(validConfig()); }); + test('keeps legacy schema-1 configurations safe by default', () => { + expect(validateAgentConfig({ + schemaVersion: 1, + enabled: true, + themeSlug: 'satoru-gojo', + launchAtLogin: true, + })).toEqual({ + schemaVersion: 1, + enabled: true, + themeSlug: 'satoru-gojo', + launchAtLogin: true, + takeoverAtLogin: false, + startupTakeoverWindowSeconds: 120, + }); + }); + test.each([ [{ schemaVersion: 2 }, 'CONFIG_SCHEMA_INVALID'], [{ enabled: 'yes' }, 'CONFIG_FIELD_INVALID'], [{ launchAtLogin: 'yes' }, 'CONFIG_FIELD_INVALID'], + [{ takeoverAtLogin: 'yes' }, 'CONFIG_FIELD_INVALID'], + [{ takeoverAtLogin: true, launchAtLogin: false }, 'CONFIG_FIELD_INVALID'], + [{ startupTakeoverWindowSeconds: 0 }, 'CONFIG_FIELD_INVALID'], + [{ startupTakeoverWindowSeconds: 301 }, 'CONFIG_FIELD_INVALID'], [{ themeSlug: '../escape' }, 'CONFIG_THEME_INVALID'], ])('rejects malformed desired configuration', (overrides, code) => { expect(() => validateAgentConfig(validConfig(overrides))).toThrowError(expect.objectContaining({ code })); diff --git a/tests/engine/installer.test.mjs b/tests/engine/installer.test.mjs index 4d4bfc4..569af89 100644 --- a/tests/engine/installer.test.mjs +++ b/tests/engine/installer.test.mjs @@ -25,7 +25,13 @@ describe('self-contained persistent installation', () => { const bootout = vi.fn(async () => {}); const result = await installPersistentAgent( - { sourceRoot: repoRoot, home, version: '0.3.0', themeSlug: 'satoru-gojo' }, + { + sourceRoot: repoRoot, + home, + version: '0.3.0', + themeSlug: 'satoru-gojo', + takeoverAtLogin: true, + }, { bootout, bootstrap, kickstart }, ); const paths = installationPaths({ home, version: '0.3.0' }); @@ -40,7 +46,14 @@ describe('self-contained persistent installation', () => { expect(await readlink(paths.currentPath)).toBe(paths.releaseDir); const config = JSON.parse(await readFile(paths.configPath, 'utf8')); - expect(config).toEqual({ schemaVersion: 1, enabled: true, themeSlug: 'satoru-gojo', launchAtLogin: true }); + expect(config).toEqual({ + schemaVersion: 1, + enabled: true, + themeSlug: 'satoru-gojo', + launchAtLogin: true, + takeoverAtLogin: true, + startupTakeoverWindowSeconds: 120, + }); expect((await stat(paths.configPath)).mode & 0o777).toBe(0o600); expect(await readFile(paths.plistPath, 'utf8')).toContain(join(paths.currentPath, 'bin/awesome-codex-themes')); expect(bootout).toHaveBeenCalledWith({ ignoreMissing: true }); diff --git a/tests/gallery/app.test.tsx b/tests/gallery/app.test.tsx index d9084b2..9cae25a 100644 --- a/tests/gallery/app.test.tsx +++ b/tests/gallery/app.test.tsx @@ -64,7 +64,9 @@ describe('theme gallery', () => { expect(dialog).toHaveTextContent(/pause.*restore.*GitHub Issue/i); await user.click(screen.getByRole('button', { name: /copy install command/i })); - expect(writeText).toHaveBeenCalledWith('./bin/awesome-codex-themes install-agent satoru-gojo'); + expect(writeText).toHaveBeenCalledWith( + './bin/awesome-codex-themes install-agent satoru-gojo --takeover-at-login', + ); expect(screen.getByRole('button', { name: /command copied/i })).toBeInTheDocument(); }); diff --git a/tests/themes/catalog.test.mjs b/tests/themes/catalog.test.mjs index 1e2dddf..13bcbd2 100644 --- a/tests/themes/catalog.test.mjs +++ b/tests/themes/catalog.test.mjs @@ -34,7 +34,7 @@ describe('theme catalog', () => { ]); expect(catalog).toHaveLength(12); expect(catalog.every((theme) => theme.compatibility.status === 'experimental')).toBe(true); - expect(catalog.every((theme) => theme.command.startsWith('./bin/awesome-codex-themes install-agent '))).toBe(true); + expect(catalog.every((theme) => theme.command.endsWith(' --takeover-at-login'))).toBe(true); expect(catalog.every((theme) => theme.preview.startsWith('/theme-assets/'))).toBe(true); });