Skip to content
Merged
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
18 changes: 17 additions & 1 deletion docs-site/src/content/docs/guides/desktop-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,26 @@ registration, including installations under `Program Files`. Previously enabled
registrations are updated once on launch. Startup entries you disabled in the tray
or Task Manager remain disabled.

## Startup safety on macOS
## Startup safety on macOS and Linux

Startup safety reports **Desktop app** protection when OpenCodex's recorded ownership,
**Start at Login** registration, and live supervision of its bundled proxy all match.
On Linux, the pinned autostart backend writes the login entry to
`~/.config/autostart/OpenCodex.desktop`, even when `$XDG_CONFIG_HOME` is set. Startup
safety reads that entry and the desktop install-id under `~/.config`. When
`$XDG_CONFIG_HOME` points elsewhere, the login session searches a different autostart
directory, so startup safety stays **At risk** instead of crediting the entry.
The entry counts only while it is not marked `Hidden=true` or
`X-GNOME-Autostart-enabled=false`, and has no `OnlyShowIn`, `NotShowIn`, or `TryExec`
condition. Its `Exec` must be an unquoted absolute path to `opencodex-desktop`, without
spaces, escapes, or field codes, followed by exactly `--autostart`. The resolved
executable must still be named `opencodex-desktop`, with its bundled `ocx` beside it.
Startup safety reads the full evidence chain twice and grants protection only when
both reads agree.

AppImage installations remain **At risk**: the autostart backend registers the outer
AppImage path, while the live desktop process runs inside its mount. Startup safety
cannot verify that relationship and does not credit AppImage protection.
A missing or stale check remains **At risk**. If the desktop app owns the proxy but
protection cannot be verified, reopen OpenCodex and check **Start at Login**. Service
and launcher installation or repair stays disabled while that ownership remains;
Expand Down
3 changes: 1 addition & 2 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,7 @@
"codex-pool-credit-policy.test.ts": "codex-integration",
"codex-sidecar-credit-policy.test.ts": "codex-integration",
"codex-credits-after-limit-main.test.ts": "codex-integration",
"service-desktop-startup-health.test.ts": "service",
"service-desktop-startup.test.ts": "service",
"service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service", "service-desktop-startup.test.ts": "service",
"startup-health-packaged-probe.test.ts": "server",
"discovered-native-models.test.ts": "codex-integration",
"responses-hosted-image-delivery.test.ts": "responses",
Expand Down
6 changes: 3 additions & 3 deletions src/codex/autostart-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { OcxConfig } from "../types";
import { getCodexRoutingKind, type CodexRoutingKind } from "./inject";
import { collectRoutingAdoption, type RoutingAdoptionEvidence } from "./routing-adoption";
import { diagnoseCodexShim, type CodexShimDiagnostic } from "./shim";
import { diagnoseMacDesktopStartup, type DesktopStartupDiagnostic } from "../service/desktop-startup";
import { diagnoseDesktopStartup, type DesktopStartupDiagnostic } from "../service/desktop-startup";

export type StartupProtection = "service" | "desktop" | "shim" | "none";
export type StartupHealthStatus = "native" | "protected" | "at-risk";
Expand Down Expand Up @@ -90,7 +90,7 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth
// We can only credit an opencodex service/shim for routing that opencodex owns.
// An arbitrary localhost gateway has an independent lifecycle that OCX cannot repair.
const ownsLocalRouting = inputs.routingKind === "opencodex-local";
const desktopEffective = inputs.platform === "darwin" && !inputs.diagnosticStale
const desktopEffective = (inputs.platform === "darwin" || inputs.platform === "linux") && !inputs.diagnosticStale
&& inputs.desktop?.owned === true && inputs.desktop.loginEnabled
&& inputs.desktop.running && inputs.desktop.viable;
const protection: StartupProtection = ownsLocalRouting && inputs.serviceViable
Expand Down Expand Up @@ -147,7 +147,7 @@ export function collectStartupHealth(
const shim = diagnostics.shim ?? diagnoseCodexShim();
const service = diagnostics.service ?? diagnoseService();
const routingKind = diagnostics.routingKind ?? getCodexRoutingKind();
const desktop = diagnostics.desktop ?? diagnoseMacDesktopStartup();
const desktop = diagnostics.desktop ?? diagnoseDesktopStartup();
const routingAdoption = diagnostics.routingAdoption
?? (routingKind === "opencodex-local" ? collectRoutingAdoption({ routingKind }) : undefined);
return deriveStartupHealth({
Expand Down
103 changes: 100 additions & 3 deletions src/service/desktop-startup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { accessSync, constants, readFileSync, realpathSync } from "node:fs";
import { accessSync, constants, readFileSync, readlinkSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import { readPid } from "../config/process-state";
import { resolveServiceOwnership, sameServiceOwnershipSubject, type ServiceOwnershipResolution } from "./state";

Expand Down Expand Up @@ -29,11 +29,28 @@ interface DesktopStartupDeps {
ownership?: () => ServiceOwnershipResolution;
readPid?: () => number | null;
run?: typeof run;
env?: NodeJS.ProcessEnv;
proc?: ProcReader;
}

/** Linux process identity from procfs: executable target and parent pid. */
interface ProcReader {
exe(pid: number): string;
parent(pid: number): number;
}
const procfs: ProcReader = {
exe: pid => readlinkSync(`/proc/${pid}/exe`),
// comm may contain spaces or parentheses, so fields are counted after the last ')'.
parent: pid => {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
},
};

/** Cheap ownership-only snapshot: no launchd/process probes on the server request path. */
export function desktopStartupOwnership(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined {
if ((deps.platform ?? process.platform) !== "darwin") return undefined;
const platform = deps.platform ?? process.platform;
if (platform !== "darwin" && platform !== "linux") return undefined;
const owner = (deps.ownership ?? resolveServiceOwnership)();
return owner.kind === "owned" && owner.ownership.owner === "desktop"
? deriveDesktopStartup({ owned: true, loginEnabled: false, running: false }) : undefined;
Expand Down Expand Up @@ -91,3 +108,83 @@ export function diagnoseMacDesktopStartup(deps: DesktopStartupDeps = {}): Deskto
return deriveDesktopStartup({ ...facts, running: false });
}
}

/** HOME-based autostart entry written by the desktop app's login item (`tauri-plugin-autostart`). */
function linuxLoginApp(entry: string): string | null {
const fields = new Map<string, string>();
let section = "";
for (const raw of entry.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
if (line.startsWith("[")) { section = line; continue; }
const at = line.indexOf("=");
if (section === "[Desktop Entry]" && at > 0 && !fields.has(line.slice(0, at).trim())) {
fields.set(line.slice(0, at).trim(), line.slice(at + 1).trim());
}
}
if (fields.get("Type") !== "Application" || fields.get("Hidden") === "true"
|| fields.get("X-GNOME-Autostart-enabled") === "false"
|| ["OnlyShowIn", "NotShowIn", "TryExec"].some(key => fields.has(key))) return null;
// Credit only the backend's simple Exec form; desktop quoting/escaping/field codes are unsupported.
const app = /^([^\s"'\\%`><~|&;$*?#()]+) --autostart$/.exec(fields.get("Exec") ?? "")?.[1];
return app && isAbsolute(app) && basename(app) === "opencodex-desktop" ? app : null;
}

/** Read-only Linux desktop ownership, HOME login entry and live parent/child checks via procfs. */
export function diagnoseLinuxDesktopStartup(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined {
if ((deps.platform ?? process.platform) !== "linux") return undefined;
const ownership = deps.ownership ?? resolveServiceOwnership;
const owner = ownership();
if (owner.kind !== "owned" || owner.ownership.owner !== "desktop") return undefined;
const facts = { owned: true, loginEnabled: false, running: false };
const proc = deps.proc ?? procfs;
const pidReader = deps.readPid ?? readPid;
try {
const env = deps.env ?? process.env;
const home = deps.home ?? homedir();
const configured = env.XDG_CONFIG_HOME;
const config = configured && isAbsolute(configured) ? configured : join(home, ".config");
// auto-launch 0.5.0 writes autostart under HOME; Tauri app_config_dir uses XDG for install-id.
// A relocated XDG_CONFIG_HOME puts that entry outside the session's autostart search path.
if (resolve(config) !== resolve(home, ".config")) return deriveDesktopStartup(facts);
const capture = () => {
const id = readFileSync(join(config, "com.opencodex.desktop", "install-id"), "utf8");
if (id.trim() !== owner.ownership.installId) return null;
const entry = readFileSync(join(home, ".config", "autostart", "OpenCodex.desktop"), "utf8");
const login = linuxLoginApp(entry);
if (!login) return null;
const app = realpathSync(login);
if (basename(app) !== "opencodex-desktop") return null;
const proxy = realpathSync(join(dirname(app), "ocx"));
if (dirname(proxy) !== dirname(app) || basename(proxy) !== "ocx") return null;
accessSync(app, constants.X_OK);
accessSync(proxy, constants.X_OK);
const pid = pidReader();
const parent = pid !== null ? proc.parent(pid) : null;
const child = pid !== null ? realpathSync(proc.exe(pid)) : null;
const parentApp = parent !== null && parent > 1 ? realpathSync(proc.exe(parent)) : null;
const running = pid !== null && child === proxy && parentApp === app && pidReader() === pid;
const currentOwner = ownership();
if (currentOwner.kind === "unknown" || !sameServiceOwnershipSubject(owner, currentOwner)) return null;
return { id, entry, login, app, proxy, pid, parent, child, parentApp, running };
};
const first = capture();
if (!first) return deriveDesktopStartup(facts);
const final = capture();
// Re-read the entire evidence chain, including files, targets and the exact parent PID.
// A changed or unreadable snapshot cannot retain protection from the first read.
if (final && JSON.stringify(first) === JSON.stringify(final)) {
facts.loginEnabled = true;
facts.running = final.running;
}
return deriveDesktopStartup(facts);
} catch {
// Unreadable login or procfs evidence never grants protection or releases the claim.
return deriveDesktopStartup({ ...facts, running: false });
}
}

/** Platform dispatch for the full (probing) desktop startup diagnostic. */
export function diagnoseDesktopStartup(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined {
return (deps.platform ?? process.platform) === "linux" ? diagnoseLinuxDesktopStartup(deps) : diagnoseMacDesktopStartup(deps);
}
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 `desktop` protection only after matching the durable app ownership, enabled and loaded login item, 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/shim install and repair controls and their copyable commands stay disabled until ownership changes. Recovery guidance asks the user to reopen OpenCodex and check Start at Login. Compiled startup probes use the standalone-aware self-launch argument builder.
Startup safety credits macOS and Linux `desktop` protection only after matching the durable app ownership, enabled and loaded login item (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/shim install and repair controls and their copyable commands stay disabled until ownership changes. Recovery guidance asks the user to reopen OpenCodex and check Start at Login. Compiled startup probes 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
2 changes: 1 addition & 1 deletion structure/ops/service-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,6 @@ src/update/async-check.ts uses the existing owner-bound registry target with a b

The desktop badge snapshot in src/update/desktop-badge.ts is process-local display state keyed by a Tauri session id. A 60-second shell heartbeat renews receipt time; entries expire after 180 seconds and the store retains at most 32 sessions. It is separate from the package version cache and from the updater job/ownership transaction. A proxy restart reports unknown until a bound desktop shell republishes; no update installation can be authorized by this snapshot.

MacOS desktop startup diagnostics use `src/service/desktop-startup.ts` to read the ownership record, launchd login registration and exact parent/child executable paths without mutating them. A durable desktop claim survives a failed identity or supervision check; only fresh matching identity, enabled login registration, and live supervision grant protection. Ownership and PID are re-read before crediting the result. The startup-health subprocess uses `selfLaunchArgv` to support both source and compiled entrypoints.
Desktop startup diagnostics use `src/service/desktop-startup.ts` to read the ownership record, the login registration (macOS: launchd; Linux: the `~/.config/autostart/OpenCodex.desktop` entry that auto-launch writes, credited only when `XDG_CONFIG_HOME` is unset or `~/.config`; it must launch an unquoted absolute `opencodex-desktop` with exactly `--autostart`, without conditional keys, and not be hidden or disabled) and exact parent/child executable paths (Linux: `/proc/<pid>/exe` and the parent pid from `/proc/<pid>/stat`) without mutating them. A durable desktop claim survives a failed identity or supervision check; only fresh matching identity, enabled login registration, and live supervision grant protection. On macOS, ownership and PID are re-read before crediting the result; on Linux, the complete evidence chain is read twice and both reads must agree. The startup-health subprocess uses `selfLaunchArgv` to support both source and compiled entrypoints.

On Linux, a dashboard update worker started from the systemd user service is launched through an executable regular file at a trusted absolute path — `/usr/bin/systemd-run`, `/bin/systemd-run`, `/usr/local/bin/systemd-run` (local installs), or `/run/current-system/sw/bin/systemd-run` (the NixOS layout) — with `--user --scope --quiet --collect` (`src/update/worker-launch.ts`), so it leaves the service cgroup before the updater stops `opencodex-proxy.service`; the default `KillMode=control-group` otherwise kills it with the proxy (#5750). The inherited `PATH` is never searched, and each candidate's resolved target — plus every ancestor directory able to substitute it — must be root-owned and not group/world-writable: a trusted-path symlink into a user-replaceable directory is skipped, as is a group-writable `/usr/local/bin`, rather than exec'd under the service account. Candidates are tried in order and a path whose no-op scope probe fails falls through to the next trusted path; the probe applies only when `INVOCATION_ID` is set, and every other case keeps the plain detached spawn. The management route resolves the launcher with `resolveSystemdRunAsync` before spawning, so first-request probing overlaps other work instead of blocking the event loop for up to twenty seconds. `--scope` moves `systemd-run` itself into the scope and then execs the worker, so the recorded PID is the worker's (`tests/update/update-worker-launch.test.ts`).
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 desktop claim from startup viability. The bounded probe verifies matching install identity, loaded and enabled login registration, and exact app-to-bundled-proxy process paths; PID or ownership changes fail closed. `src/server/startup-health-cache.ts` launches source and compiled probes with `selfLaunchArgv`, revokes stale protection, and preserves desktop recovery guidance.
the runtime, which is what every record written before the field existed says. `src/service/desktop-startup.ts` separates the durable macOS and Linux desktop claim from startup viability. The bounded probe verifies matching install identity, loaded and enabled login registration, and exact app-to-bundled-proxy process paths. 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.

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
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"codex-credits-after-limit.test.ts": "codex-integration",
"codex-pool-credit-policy.test.ts": "codex-integration",
"codex-sidecar-credit-policy.test.ts": "codex-integration",
"codex-credits-after-limit-main.test.ts": "codex-integration", "service-desktop-startup-health.test.ts": "service",
"codex-credits-after-limit-main.test.ts": "codex-integration", "service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service",
"service-desktop-startup.test.ts": "service", "startup-health-packaged-probe.test.ts": "server",
"discovered-native-models.test.ts": "codex-integration", "responses-hosted-image-delivery.test.ts": "responses",
"responses-hosted-image-display.test.ts": "responses", "responses-hosted-image-limits.test.ts": "responses",
Expand Down
Loading
Loading