Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 55 additions & 6 deletions src/cli/ensure-desktop-cold-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -22,7 +23,7 @@
*/
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 { getConfigDir } from "../config/paths";
Expand Down Expand Up @@ -220,11 +221,59 @@ 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.
if (registration.Label !== DESKTOP_PLIST_LABEL || !Array.isArray(args)
|| (args.length !== 1 && args.length !== 2) || typeof args[0] !== "string"
|| (args.length === 2 && args[1] !== "--autostart")
|| (registration.Program !== undefined && registration.Program !== args[0])) return null;
const executable = realpathSync(args[0]);
if (!isDesktopExecutable(executable, "darwin")) 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 };
return info.CFBundleIdentifier === DESKTOP_BUNDLE_ID ? bundle : null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** 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<typeof resolveServiceOwnership>;
} = {}): 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; }
}
Expand Down
2 changes: 1 addition & 1 deletion structure/gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 runs the path the consented installation's own login registration recorded, after the app-local install id is checked against the recorded claim and the resolved bundle path ends in `.app`; a bundle-identifier lookup never chooses the launch target, so a claim whose registration or id evidence cannot be verified is refused rather than guessed. 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
Expand Down
108 changes: 103 additions & 5 deletions tests/cli/cli-ensure-desktop-cold-start.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
Expand Down Expand Up @@ -158,11 +158,109 @@ 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?: string[]; bareArgs?: boolean; label?: string; identifier?: string;
plist?: "present" | "absent"; executable?: "present" | "absent"; bundleName?: string;
} = {}) {
const home = mkdtempSync(join(tmpdir(), "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 !== "absent") { writeFileSync(executable, ""); chmodSync(executable, 0o755); }
writeFileSync(join(bundle, "Contents", "Info.plist"), "");
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 });
if (options.plist !== "absent") writeFileSync(join(home, "Library", "LaunchAgents", "OpenCodex.plist"), "");
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);
const run = (command: string, args: string[]) => {
seen.push([command, ...args]);
const target = args[args.length - 1] ?? "";
if (command !== "/usr/bin/plutil") return "";
if (target.endsWith(`${"OpenCodex"}.plist`)) {
return JSON.stringify({
Label: options.label ?? "OpenCodex",
ProgramArguments: options.bareArgs ? [executable] : (options.args ?? [executable, "--autostart"]),
});
}
if (target.endsWith("Info.plist")) return JSON.stringify({ CFBundleIdentifier: options.identifier ?? "com.opencodex.desktop" });
return "";
};
return { home, bundle, seen, run };
}
const launch = (fixture: ReturnType<typeof fakeDesktopHome>, ownership = () => 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("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); }
expect(launchDesktopApp({ platform: "darwin", home: mkdtempSync(join(tmpdir(), "ocx-desktop-empty-")), ownership: () => owned, run: () => { throw new Error("no app"); } })).toBe(false);
expect(launchDesktopApp({ platform: "linux", run: () => { throw new Error("must not run"); } })).toBe(false);
});
});
Expand Down
Loading