diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 4ecad314138..a6c94f31389 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -116,8 +116,12 @@ means the registration could not be read. While the app runs the proxy, the Star background service installer disabled; to use it instead, quit OpenCodex and run `ocx service install` in a terminal. The Codex launcher shim can still be installed there. When a Codex launch finds no proxy, the shim waits for OpenCodex to restart it, opens -OpenCodex on macOS if it quit, and otherwise reports an error instead of starting a second -proxy. If the shim reads "activation not confirmed", add +OpenCodex on macOS if it quit and its recorded ownership matches a valid **Start at Login** +registration, and otherwise reports an error instead of starting a second proxy. Automatic +reopening selects that registration's app bundle path and checks that its declared main +executable matches the registered Desktop executable. If registration is missing, stale or +inconsistent, open OpenCodex manually; another copy with the same bundle identifier is not +selected as a fallback. If the shim reads "activation not confirmed", add `. ~/.opencodex/codex-shell-env.sh` to your shell startup file after other PATH setup. The bundled CLI is `/Applications/OpenCodex.app/Contents/MacOS/ocx` on macOS and diff --git a/docs-site/src/content/docs/ko/guides/desktop-app.md b/docs-site/src/content/docs/ko/guides/desktop-app.md index 446a6e20c64..37c38585ff1 100644 --- a/docs-site/src/content/docs/ko/guides/desktop-app.md +++ b/docs-site/src/content/docs/ko/guides/desktop-app.md @@ -91,8 +91,12 @@ macOS에서 메뉴의 **Start at Login** 항목은 시작 안전 페이지도 **status unknown**은 등록을 읽지 못했다는 뜻입니다. 앱이 프록시를 실행하는 동안 시작 안전 페이지는 백그라운드 서비스 설치를 막아 둡니다. 서비스를 대신 쓰려면 OpenCodex를 종료하고 터미널에서 `ocx service install`을 실행하세요. Codex 런처 shim은 이 페이지에서 설치할 수 있습니다. -Codex를 실행할 때 프록시가 없으면 shim은 OpenCodex가 프록시를 다시 띄우기를 기다리고, macOS에서 -앱이 종료돼 있으면 앱을 열며, 그 밖의 경우에는 두 번째 프록시를 띄우지 않고 오류를 알립니다. +Codex를 실행할 때 프록시가 없으면 shim은 OpenCodex가 프록시를 다시 띄우기를 기다립니다. macOS에서 +앱이 종료돼 있고 기록된 소유 정보가 유효한 **Start at Login** 등록과 일치하면 앱을 열고, 그 밖의 +경우에는 두 번째 프록시를 띄우지 않고 오류를 알립니다. 자동으로 다시 열 때는 그 등록에 기록된 앱 +번들 경로를 사용하고, 번들이 선언한 주 실행 파일이 등록된 Desktop 실행 파일과 같은지 확인합니다. +등록이 없거나 오래됐거나 서로 맞지 않으면 OpenCodex를 직접 여세요. 번들 식별자가 같은 다른 사본을 +대신 고르지 않습니다. shim이 "활성화 미확인"으로 보이면 셸 시작 파일의 다른 PATH 설정 뒤에 `. ~/.opencodex/codex-shell-env.sh`를 추가하세요. diff --git a/src/cli/ensure-desktop-cold-start.ts b/src/cli/ensure-desktop-cold-start.ts index dd7a94de2c4..60b5f371310 100644 --- a/src/cli/ensure-desktop-cold-start.ts +++ b/src/cli/ensure-desktop-cold-start.ts @@ -10,8 +10,9 @@ * * Rules, in order: * - A running Desktop app is the supervisor. Wait for its proxy; never start a competing one. - * - A durable Desktop claim with no running app: start Desktop (hidden, as a login launch would) - * where that is possible, then wait. Elsewhere, fail with guidance. A missing Desktop process + * - A durable Desktop claim with no running app: start Desktop (hidden, by the path its own + * login registration recorded — LaunchServices never picks the bundle) where that is + * verifiable, then wait. Elsewhere, fail with guidance. A missing Desktop process * never authorizes a CLI proxy by itself; handing ownership to the CLI stays an explicit * `ocx service install` consent step. * - Ownership or the process list that cannot be read is not evidence of absence: refuse. @@ -22,9 +23,9 @@ */ import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { existsSync, linkSync, readdirSync, readFileSync, readlinkSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { accessSync, constants, existsSync, linkSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { basename, dirname, join, win32 } from "node:path"; +import { basename, dirname, join, posix, win32 } from "node:path"; import { getConfigDir } from "../config/paths"; import { resolveServiceOwnership } from "../service/state"; @@ -220,11 +221,63 @@ export function inspectDesktopProcess(options: DesktopProcessOptions = {}): Desk } } -/** Start the macOS app hidden, the same way its login item does. */ -export function launchDesktopApp(options: { platform?: NodeJS.Platform; run?: Execute } = {}): boolean { +const DESKTOP_BUNDLE_ID = "com.opencodex.desktop"; +const DESKTOP_PLIST_LABEL = "OpenCodex"; + +/** + * The app bundle the consented installation itself registered as its Start-at-Login program — + * not whichever bundle LaunchServices would resolve for our identifier. `open -b` answers a + * mutable per-user registry: any planted bundle claiming {@link DESKTOP_BUNDLE_ID} could be + * selected and executed in the real app's place. The recorded claim, this app's own install-id + * store, and the login registration it wrote are the durable evidence of which installation + * that is; a claim whose registration is absent, malformed, stale or points off a Desktop + * executable refuses the launch instead of guessing. + */ +function consentedDesktopBundle(home: string, installId: string, run: Execute): string | null { + const localId = readFileSync( + join(home, "Library", "Application Support", DESKTOP_BUNDLE_ID, "install-id"), "utf8", + ).trim(); + if (!localId || localId !== installId) return null; + const plistPath = realpathSync(join(home, "Library", "LaunchAgents", `${DESKTOP_PLIST_LABEL}.plist`)); + const registration = JSON.parse( + run("/usr/bin/plutil", ["-convert", "json", "-o", "-", plistPath], 3_000), + ) as { Label?: unknown; ProgramArguments?: unknown; Program?: unknown }; + const args = registration.ProgramArguments; + // The shapes auto-launch writes: [program] or [program, "--autostart"]; see the migration + // rules in desktop/src-tauri/src/login_registration.rs, which classify anything else Leave. + // This is a macOS plist path, regardless of the host running an injected launch probe. + if (registration.Label !== DESKTOP_PLIST_LABEL || !Array.isArray(args) + || (args.length !== 1 && args.length !== 2) || typeof args[0] !== "string" || !posix.isAbsolute(args[0]) + || (args.length === 2 && args[1] !== "--autostart") + || (registration.Program !== undefined && registration.Program !== args[0])) return null; + const executable = realpathSync(args[0]); + if (!isDesktopExecutable(executable, "darwin") || !statSync(executable).isFile()) return null; + accessSync(executable, constants.X_OK); + const bundle = dirname(dirname(dirname(executable))); + if (!bundle.endsWith(".app")) return null; + const info = JSON.parse( + run("/usr/bin/plutil", ["-convert", "json", "-o", "-", join(bundle, "Contents", "Info.plist")], 3_000), + ) as { CFBundleIdentifier?: unknown; CFBundleExecutable?: unknown }; + // `open` uses this selector, so checking only the registration and identifier would + // validate one binary while launching another. The registered path is already canonical. + if (info.CFBundleIdentifier !== DESKTOP_BUNDLE_ID || info.CFBundleExecutable !== "opencodex-desktop") return null; + return realpathSync(join(bundle, "Contents", "MacOS", info.CFBundleExecutable)) === executable ? bundle : null; +} + +/** Start the consented macOS app hidden, by the path its own login registration recorded. */ +export function launchDesktopApp(options: { + platform?: NodeJS.Platform; + run?: Execute; + home?: string; + ownership?: () => ReturnType; +} = {}): boolean { if ((options.platform ?? process.platform) !== "darwin") return false; try { - (options.run ?? execute)("/usr/bin/open", ["-g", "-b", "com.opencodex.desktop", "--args", "--autostart"], 10_000); + const owner = (options.ownership ?? resolveServiceOwnership)(); + if (owner.kind !== "owned" || owner.ownership.owner !== "desktop") return false; + const bundle = consentedDesktopBundle(options.home ?? homedir(), owner.ownership.installId, options.run ?? execute); + if (bundle === null) return false; + (options.run ?? execute)("/usr/bin/open", ["-g", bundle, "--args", "--autostart"], 10_000); return true; } catch { return false; } } diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 30a21e21239..1bf690e4610 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -499,7 +499,7 @@ unvalidated Bun builds is unchanged (`src/lib/bun-stream-caps.ts`). ## Startup safety -Startup safety credits macOS and Linux `desktop` protection only after matching the durable app ownership or live supervision, an enabled login registration (macOS: loaded in this session or registered for the next login; Linux: enabled XDG autostart entry), and live app-to-bundled-proxy process relationship. This does not claim an independently installed CLI service. Missing or stale evidence remains at risk. The durable desktop claim remains visible when identity, login registration, or supervision fails; service install and repair controls and their copyable command stay disabled while the app owns or supervises the runtime. Shim controls stay enabled because `ocx ensure` (`src/cli/ensure-desktop-cold-start.ts`) waits for a running Desktop (matched by executable identity), relaunches a claimed macOS Desktop under a pid-held launch lease, and otherwise fails rather than starting a CLI proxy, including when ownership or the process list cannot be read. Under Desktop the shim installer child gets the login-shell PATH (`src/server/login-shell-path.ts`); an intact overlay not active on the dashboard's PATH reports `shimActivationPending` and shows the activation step instead of Repair. The Desktop row says "Running · Start at Login not verified" (`desktopBadgeKey` in `gui/src/startup-health-ui.ts`) for a fresh reading of a running owned or supervised app without verified login registration, and "Installed but unhealthy" otherwise; failed or stale readings never get the narrower label. Recovery guidance asks the user to check Start at Login and, when it is already checked, the system's login-item allowance. Compiled startup probes and the dashboard installer child (`runCliInstall` in `src/server/startup-action-control.ts`) use the standalone-aware self-launch argument builder. +Startup safety credits macOS and Linux `desktop` protection only after matching the durable app ownership or live supervision, an enabled login registration (macOS: loaded in this session or registered for the next login; Linux: enabled XDG autostart entry), and live app-to-bundled-proxy process relationship. This does not claim an independently installed CLI service. Missing or stale evidence remains at risk. The durable desktop claim remains visible when identity, login registration, or supervision fails; service install and repair controls and their copyable command stay disabled while the app owns or supervises the runtime. Shim controls stay enabled because `ocx ensure` (`src/cli/ensure-desktop-cold-start.ts`) waits for a running Desktop (matched by executable identity), relaunches a claimed macOS Desktop under a pid-held launch lease, and otherwise fails rather than starting a CLI proxy, including when ownership or the process list cannot be read. The relaunch selects the absolute path recorded by the installation's login registration, after the app-local install id matches the claim, the canonical executable is a regular executable file inside a `.app`, and that bundle's `CFBundleIdentifier` and `CFBundleExecutable` identify the same Desktop executable. A bundle-identifier lookup never chooses the launch target; missing, relative or inconsistent registration evidence refuses with manual-open guidance. Under Desktop the shim installer child gets the login-shell PATH (`src/server/login-shell-path.ts`); an intact overlay not active on the dashboard's PATH reports `shimActivationPending` and shows the activation step instead of Repair. The Desktop row says "Running · Start at Login not verified" (`desktopBadgeKey` in `gui/src/startup-health-ui.ts`) for a fresh reading of a running owned or supervised app without verified login registration, and "Installed but unhealthy" otherwise; failed or stale readings never get the narrower label. Recovery guidance asks the user to check Start at Login and, when it is already checked, the system's login-item allowance. Compiled startup probes and the dashboard installer child (`runCliInstall` in `src/server/startup-action-control.ts`) use the standalone-aware self-launch argument builder. **Startup safety** is reachable by route (`/#startup`) and rendered by the app, but it is not a sidebar entry: it is entered from the dashboard's startup-state row, which links there whether the diff --git a/structure/runtime.md b/structure/runtime.md index d4e5739596f..12a28929d04 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -543,7 +543,7 @@ Codex compaction uses a request-local model override for the configured triggers state, beside the install provenance. The claim carries an `owner` (`cli` or `desktop`), an opaque `installId` naming the owning installation rather than the user or the machine, and a `consentGeneration`. An absent claim means the CLI install that registered the service owns -the runtime, which is what every record written before the field existed says. `src/service/desktop-startup.ts` separates the durable macOS, Linux and Windows desktop claim from startup viability. Windows Desktop identity is read from `%APPDATA%\com.opencodex.desktop\install-id` and checked against live parentage; Windows Start at Login is unverified and Desktop never receives startup protection credit there. Independent [live Desktop supervision](desktop-shell.md#runtime-ownership-from-the-apps-side) can also grant startup protection without a claim when that app's login registration is verified; it never becomes ownership. On Linux, auto-launch 0.5.0 writes the login entry under HOME/.config/autostart, so a relocated XDG_CONFIG_HOME (outside that search path) is uncredited. Conditional entries and ambiguous Exec syntax are uncredited; the resolved desktop basename must match and its resolved ocx stays in the same directory. Both complete evidence reads must agree on ownership, install-id, entry contents, executable targets, PID and parent relationship; changes fail closed. AppImage registrations are uncredited because they name the outer image rather than the mounted executable. `src/server/startup-health-cache.ts` launches source and compiled probes with `selfLaunchArgv`, revokes stale protection, and preserves desktop recovery guidance. An enabled, identity-matching macOS LaunchAgent registration remains viable when `launchctl print` specifically proves the job is absent in the current session. `src/service/desktop-startup.ts` reports `registered-not-loaded` for next login; wrong plist paths/arguments/label, disabled override, unknown failures and supervisor races still refuse credit. `src/codex/autostart-health.ts` distinguishes this protected registration in restart safety. `src/service/linux-linger.ts` uses bounded `loginctl show-user` to report already-enabled linger, advise activation only when disabled, or explicitly report unavailable diagnosis. +the runtime, which is what every record written before the field existed says. `src/service/desktop-startup.ts` separates the durable macOS, Linux and Windows desktop claim from startup viability. Windows Desktop identity is read from `%APPDATA%\com.opencodex.desktop\install-id` and checked against live parentage; Windows Start at Login is unverified and Desktop never receives startup protection credit there. Independent [live Desktop supervision](desktop-shell.md#runtime-ownership-from-the-apps-side) can also grant startup protection without a claim when that app's login registration is verified; it never becomes ownership. On Linux, auto-launch 0.5.0 writes the login entry under HOME/.config/autostart, so a relocated XDG_CONFIG_HOME (outside that search path) is uncredited. Conditional entries and ambiguous Exec syntax are uncredited; the resolved desktop basename must match and its resolved ocx stays in the same directory. Both complete evidence reads must agree on ownership, install-id, entry contents, executable targets, PID and parent relationship; changes fail closed. AppImage registrations are uncredited because they name the outer image rather than the mounted executable. `src/server/startup-health-cache.ts` launches source and compiled probes with `selfLaunchArgv`, revokes stale protection, and preserves desktop recovery guidance. An enabled, identity-matching macOS LaunchAgent registration remains viable when `launchctl print` specifically proves the job is absent in the current session. `src/service/desktop-startup.ts` reports `registered-not-loaded` for next login; wrong plist paths/arguments/label, disabled override, unknown failures and supervisor races still refuse credit. `src/codex/autostart-health.ts` distinguishes this protected registration in restart safety. `src/service/linux-linger.ts` uses bounded `loginctl show-user` to report already-enabled linger, advise activation only when disabled, or explicitly report unavailable diagnosis. `src/cli/ensure-desktop-cold-start.ts` launches a claimed Desktop only on macOS. Its LaunchAgent executable path must be POSIX-absolute, including when the launch probe is exercised on another host; Windows drive paths do not satisfy that plist contract. Launches on Linux and Windows refuse without invoking a command. Every write goes through `swapServiceInstallState`. With a custom home, the default-home record is the authority every writer can derive and the active-home record is a compatibility diff --git a/tests/cli/cli-ensure-desktop-cold-start.test.ts b/tests/cli/cli-ensure-desktop-cold-start.test.ts index 0e0289ce2e1..5b1ec4ce80e 100644 --- a/tests/cli/cli-ensure-desktop-cold-start.test.ts +++ b/tests/cli/cli-ensure-desktop-cold-start.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join, relative } from "node:path"; import { acquireDesktopLaunchLease, DESKTOP_COLD_START_TIMEOUT_MS, @@ -15,6 +16,7 @@ import { type DesktopOwnershipState, type DesktopProcessState, } from "../../src/cli/ensure-desktop-cold-start"; +import type { ServiceOwnershipResolution } from "../../src/service/state"; import { removeTreeWithRetry } from "../helpers/remove-tree"; interface Calls { launches: number; waits: number[]; released: number } @@ -158,12 +160,203 @@ describe("Desktop process detection by executable identity", () => { expect(inspectDesktopProcess({ platform: "win32", env, run: () => { throw new Error("denied"); } })).toBe("unknown"); }); - test("only macOS launches Desktop, hidden, through its bundle identifier", () => { + const owned = { + kind: "owned" as const, + ownership: { owner: "desktop" as const, installId: "install-a", consentGeneration: 1 }, + revision: 3, + }; + /** A fake consented install: app-local install-id, a LaunchAgent plist, and a bundle on disk. */ + function fakeDesktopHome(options: { + installId?: string; args?: unknown; bareArgs?: boolean; label?: string; identifier?: string; + plist?: "present" | "absent"; executable?: "present" | "absent" | "directory"; bundleName?: string; + bundleExecutable?: unknown; program?: unknown; relativeProgram?: boolean; + } = {}) { + // Keep the bundle on cwd's drive: Windows relative() returns an absolute path across drives. + const fixtureRoot = join(process.cwd(), ".tmp"); + mkdirSync(fixtureRoot, { recursive: true }); + const home = mkdtempSync(join(fixtureRoot, "ocx-desktop-home-")); + const bundle = join(home, "Applications", options.bundleName ?? "OpenCodex.app"); + const executable = join(bundle, "Contents", "MacOS", "opencodex-desktop"); + mkdirSync(join(bundle, "Contents", "MacOS"), { recursive: true }); + if (options.executable === "directory") mkdirSync(executable); + else if (options.executable !== "absent") { writeFileSync(executable, ""); chmodSync(executable, 0o755); } + const info = { CFBundleIdentifier: options.identifier ?? "com.opencodex.desktop", + CFBundleExecutable: "bundleExecutable" in options ? options.bundleExecutable : "opencodex-desktop" }; + writeFileSync(join(bundle, "Contents", "Info.plist"), JSON.stringify(info)); + const store = join(home, "Library", "Application Support", "com.opencodex.desktop"); + mkdirSync(store, { recursive: true }); + if (options.installId !== undefined) writeFileSync(join(store, "install-id"), `${options.installId}\n`); + mkdirSync(join(home, "Library", "LaunchAgents"), { recursive: true }); + // A root-relative POSIX spelling also reaches this same-drive fixture on Windows. + const absoluteProgram = process.platform === "win32" ? executable.slice(2).replaceAll("\\", "/") : executable; + const program = options.relativeProgram ? relative(process.cwd(), executable) : absoluteProgram; + const registration = { Label: options.label ?? "OpenCodex", RunAtLoad: true, + ProgramArguments: options.bareArgs ? [program] : (options.args ?? [program, "--autostart"]), + ...("program" in options ? { Program: options.program } : {}) }; + if (options.plist !== "absent") writeFileSync(join(home, "Library", "LaunchAgents", "OpenCodex.plist"), JSON.stringify(registration)); const seen: string[][] = []; - expect(launchDesktopApp({ platform: "darwin", run: (command, args) => { seen.push([command, ...args]); return ""; } })).toBe(true); - expect(seen).toEqual([["/usr/bin/open", "-g", "-b", "com.opencodex.desktop", "--args", "--autostart"]]); - expect(launchDesktopApp({ platform: "darwin", run: () => { throw new Error("no app"); } })).toBe(false); - expect(launchDesktopApp({ platform: "linux", run: () => { throw new Error("must not run"); } })).toBe(false); + const run = (command: string, args: string[]) => { + seen.push([command, ...args]); + const target = args[args.length - 1] ?? ""; + if (command !== "/usr/bin/plutil") return ""; + return readFileSync(target, "utf8"); + }; + return { home, bundle, executable, program, seen, run }; + } + const launch = (fixture: ReturnType, ownership: () => ServiceOwnershipResolution = () => owned) => + launchDesktopApp({ platform: "darwin", home: fixture.home, ownership, run: fixture.run }); + + test("a Desktop claim launches the app its own login registration recorded, never a bundle-id lookup", () => { + const fixture = fakeDesktopHome({ installId: "install-a" }); + try { + expect(launch(fixture)).toBe(true); + expect(fixture.seen.at(-1)).toEqual(["/usr/bin/open", "-g", realpathSync(fixture.bundle), "--args", "--autostart"]); + expect(fixture.seen.flat().includes("-b")).toBe(false); + } finally { removeTreeWithRetry(fixture.home); } + }); + + test("the registration shape auto-launch writes without --autostart is still the consented path", () => { + const fixture = fakeDesktopHome({ installId: "install-a", bareArgs: true }); + try { + expect(launch(fixture)).toBe(true); + expect(fixture.seen.at(-1)).toEqual(["/usr/bin/open", "-g", realpathSync(fixture.bundle), "--args", "--autostart"]); + } finally { removeTreeWithRetry(fixture.home); } + }); + + test("a non-app directory refuses the launch without reading its Info.plist or waiting for a proxy", async () => { + for (const bundleName of ["OpenCodex", "OpenCodex.app.backup"]) { + const fixture = fakeDesktopHome({ installId: "install-a", bundleName }); + try { + const calls = fresh(); + const dependencies = deps({ ownership: "desktop", process: "absent" }, calls); + dependencies.launchDesktop = () => launch(fixture); + const result = await runDesktopColdStart(dependencies); + expect(result.kind === "failed" && result.message).toContain("Open OpenCodex manually"); + expect(calls.waits).toEqual([]); + expect(calls.released).toBe(1); + expect(fixture.seen).toHaveLength(1); + expect(fixture.seen[0]?.[0]).toBe("/usr/bin/plutil"); + expect(fixture.seen[0]?.at(-1)).toEndWith("OpenCodex.plist"); + } finally { removeTreeWithRetry(fixture.home); } + } + }); + + test("an existing relative registration path cannot select a bundle from the caller's working directory", () => { + const fixture = fakeDesktopHome({ installId: "install-a", relativeProgram: true }); + try { + expect(isAbsolute(fixture.program)).toBe(false); + expect(realpathSync(fixture.program)).toBe(realpathSync(fixture.executable)); + expect(launch(fixture)).toBe(false); + expect(fixture.seen.some(call => call[0] === "/usr/bin/open")).toBe(false); + } finally { removeTreeWithRetry(fixture.home); } + }); + + test("bundle launch requires the declared main executable to match the registered regular file", () => { + for (const options of [{ bundleExecutable: "other" }, { bundleExecutable: undefined }, + { bundleExecutable: "../opencodex-desktop" }, { bundleExecutable: ["opencodex-desktop"] }, + { executable: "directory" as const }, { executable: "absent" as const }]) { + const fixture = fakeDesktopHome({ installId: "install-a", ...options }); + try { + expect(launch(fixture)).toBe(false); + expect(fixture.seen.some(call => call[0] === "/usr/bin/open")).toBe(false); + } finally { removeTreeWithRetry(fixture.home); } + } + }); + + test("malformed registration arguments and a conflicting Program never launch", () => { + for (const options of [{ args: [] }, { args: [123] }, { args: "opencodex-desktop" }, + { args: ["/some/app", "--other"] }, { args: ["/some/app", "--autostart", "extra"] }, + { args: ["C:\\OpenCodex.app\\Contents\\MacOS\\opencodex-desktop"] }, + { args: ["C:OpenCodex.app\\Contents\\MacOS\\opencodex-desktop"] }, + { program: "/another/app" }, { program: null }]) { + const fixture = fakeDesktopHome({ installId: "install-a", ...options }); + try { expect(launch(fixture)).toBe(false); expect(fixture.seen).toHaveLength(1); } + finally { removeTreeWithRetry(fixture.home); } + } + }); + + test("a matching optional Program is accepted, and conversion or open failures refuse", () => { + const fixture = fakeDesktopHome({ installId: "install-a" }); + try { + const plist = join(fixture.home, "Library", "LaunchAgents", "OpenCodex.plist"); + const registration = JSON.parse(readFileSync(plist, "utf8")); + writeFileSync(plist, JSON.stringify({ ...registration, Program: fixture.program })); + expect(launch(fixture)).toBe(true); + for (const failingCommand of ["/usr/bin/plutil", "/usr/bin/open"]) { + expect(launchDesktopApp({ platform: "darwin", home: fixture.home, ownership: () => owned, + run: (command, args) => { if (command === failingCommand) throw new Error("fixture failure"); return fixture.run(command, args); }, + })).toBe(false); + } + writeFileSync(plist, "malformed plist"); + expect(launch(fixture)).toBe(false); + } finally { removeTreeWithRetry(fixture.home); } + }); + + test.skipIf(process.platform === "win32")("a non-executable registered file refuses launch", () => { + const fixture = fakeDesktopHome({ installId: "install-a" }); + try { + chmodSync(fixture.executable, 0o644); + expect(launch(fixture)).toBe(false); + expect(fixture.seen.some(call => call[0] === "/usr/bin/open")).toBe(false); + } finally { removeTreeWithRetry(fixture.home); } + }); + + test.skipIf(process.platform !== "darwin")("native plutil decodes the producer's XML registration and bundle selector", () => { + const fixture = fakeDesktopHome({ installId: "install-a" }); + try { + const plist = join(fixture.home, "Library", "LaunchAgents", "OpenCodex.plist"); + for (const path of [plist, join(fixture.bundle, "Contents", "Info.plist")]) { + execFileSync("/usr/bin/plutil", ["-convert", "xml1", path]); + } + const seen: string[][] = []; + const run = (command: string, args: string[]) => { + seen.push([command, ...args]); + if (command === "/usr/bin/open") return ""; + return execFileSync(command, args, { encoding: "utf8" }); + }; + expect(launchDesktopApp({ platform: "darwin", home: fixture.home, ownership: () => owned, run })).toBe(true); + expect(seen.at(-1)).toEqual(["/usr/bin/open", "-g", realpathSync(fixture.bundle), "--args", "--autostart"]); + writeFileSync(join(fixture.bundle, "Contents", "Info.plist"), JSON.stringify({ + CFBundleIdentifier: "com.opencodex.desktop", CFBundleExecutable: "other", + })); + seen.length = 0; + expect(launchDesktopApp({ platform: "darwin", home: fixture.home, ownership: () => owned, run })).toBe(false); + expect(seen.some(call => call[0] === "/usr/bin/open")).toBe(false); + } finally { removeTreeWithRetry(fixture.home); } + }); + + test("a claim without matching install-id, registration or Desktop executable refuses the launch", () => { + for (const fixture of [ + fakeDesktopHome({ installId: "other-install" }), + fakeDesktopHome({ installId: "install-a", plist: "absent" }), + fakeDesktopHome({ installId: "install-a", label: "OtherApp" }), + fakeDesktopHome({ installId: "install-a", args: [join(tmpdir(), "elsewhere", "opencodex-desktop")] }), + fakeDesktopHome({ installId: "install-a", args: ["executable-only-name"] }), + fakeDesktopHome({ installId: "install-a", identifier: "com.example.fake" }), + ]) { + try { + expect(launch(fixture)).toBe(false); + expect(fixture.seen.flat().includes("com.opencodex.desktop")).toBe(false); + expect(fixture.seen[0]?.[0]).not.toBe("/usr/bin/open"); + } finally { removeTreeWithRetry(fixture.home); } + } + // install-id store absent entirely. + const noId = fakeDesktopHome(); + try { expect(launch(noId)).toBe(false); } finally { removeTreeWithRetry(noId.home); } + }); + + test("a claim that is not a recorded Desktop owner, or a failing lookup, never launches", () => { + const fixture = fakeDesktopHome({ installId: "install-a" }); + try { + expect(launch(fixture, () => ({ kind: "none", revision: 2 }))).toBe(false); + expect(launch(fixture, () => ({ kind: "owned", ownership: { owner: "cli", installId: "install-a", consentGeneration: 1 }, revision: 2 }))).toBe(false); + expect(fixture.seen).toEqual([]); + } finally { removeTreeWithRetry(fixture.home); } + for (const platform of ["linux", "win32"] as const) { + const commands: string[] = []; + expect(launchDesktopApp({ platform, run: command => { commands.push(command); return ""; } })).toBe(false); + expect(commands).toEqual([]); + } }); });