From 2d11ec5b3d1ca8b0643f5a63369d28a4c918f472 Mon Sep 17 00:00:00 2001 From: Sungyong Cho Date: Tue, 6 Oct 2026 13:27:54 +0200 Subject: [PATCH 1/5] fix(service): credit Linux desktop Start at Login as restart protection in doctor --- .../src/content/docs/guides/desktop-app.md | 5 +- scripts/test-layout/layout.json | 1 + src/codex/autostart-health.ts | 6 +- src/service/desktop-startup.ts | 85 +++++++++++- structure/gui-and-management-api.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/runtime.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- .../service-desktop-startup-linux.test.ts | 125 ++++++++++++++++++ 9 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 tests/service/service-desktop-startup-linux.test.ts diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 4d25d6f664b..85f7083f453 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -71,10 +71,13 @@ 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 login registration is the XDG autostart entry +`~/.config/autostart/OpenCodex.desktop` (under `$XDG_CONFIG_HOME` when set), and it counts only +while it is not marked `Hidden=true` or `X-GNOME-Autostart-enabled=false`. 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; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index cb8d63e4681..04dd9c1645b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -74,6 +74,7 @@ "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-linux.test.ts": "service", "service-desktop-startup.test.ts": "service", "startup-health-packaged-probe.test.ts": "server", "discovered-native-models.test.ts": "codex-integration", diff --git a/src/codex/autostart-health.ts b/src/codex/autostart-health.ts index f9cee5c7942..58768c4a42b 100644 --- a/src/codex/autostart-health.ts +++ b/src/codex/autostart-health.ts @@ -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"; @@ -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 @@ -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({ diff --git a/src/service/desktop-startup.ts b/src/service/desktop-startup.ts index 551bca9507f..f321c82e1b4 100644 --- a/src/service/desktop-startup.ts +++ b/src/service/desktop-startup.ts @@ -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 { dirname, isAbsolute, join } from "node:path"; import { readPid } from "../config/process-state"; import { resolveServiceOwnership, sameServiceOwnershipSubject, type ServiceOwnershipResolution } from "./state"; @@ -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; @@ -91,3 +108,65 @@ export function diagnoseMacDesktopStartup(deps: DesktopStartupDeps = {}): Deskto return deriveDesktopStartup({ ...facts, running: false }); } } + +/** XDG autostart entry written by the desktop app's login item (`tauri-plugin-autostart`). */ +function linuxLoginApp(entry: string): string | null { + const fields = new Map(); + 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") return null; + const exec = /^(?:"([^"]+)"|(\S+)) --autostart$/.exec(fields.get("Exec") ?? ""); + const app = exec?.[1] ?? exec?.[2]; + return app && isAbsolute(app) && app.endsWith("/opencodex-desktop") ? app : null; +} + +/** Read-only Linux desktop ownership, XDG 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 configured = env.XDG_CONFIG_HOME; + const config = configured && isAbsolute(configured) ? configured : join(deps.home ?? homedir(), ".config"); + const id = readFileSync(join(config, "com.opencodex.desktop", "install-id"), "utf8").trim(); + if (id !== owner.ownership.installId) return deriveDesktopStartup(facts); + const login = linuxLoginApp(readFileSync(join(config, "autostart", "OpenCodex.desktop"), "utf8")); + if (!login) return deriveDesktopStartup(facts); + const app = realpathSync(login); + const proxy = realpathSync(join(dirname(app), "ocx")); + accessSync(app, constants.X_OK); + accessSync(proxy, constants.X_OK); + facts.loginEnabled = true; + const pid = pidReader(); + if (pid !== null) { + const parent = proc.parent(pid); + facts.running = realpathSync(proc.exe(pid)) === proxy && parent > 1 + && realpathSync(proc.exe(parent)) === app && pidReader() === pid; + } + const currentOwner = ownership(); + if (currentOwner.kind === "unknown" || !sameServiceOwnershipSubject(owner, currentOwner)) facts.running = false; + 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); +} diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index bdbd0bfa793..1cc4c3c10f5 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 `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 diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 764e0e5a811..2c4f84e9f8b 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -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 `$XDG_CONFIG_HOME/autostart/OpenCodex.desktop` entry, which must launch an absolute `opencodex-desktop` with `--autostart` and not be hidden or disabled) and exact parent/child executable paths (Linux: `/proc//exe` and the parent pid from `/proc//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. Ownership and PID are re-read before crediting the result. 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`). diff --git a/structure/runtime.md b/structure/runtime.md index a4ba9577f82..f3492429d02 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 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; 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. 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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d993598182c..ab0e7415349 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -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", diff --git a/tests/service/service-desktop-startup-linux.test.ts b/tests/service/service-desktop-startup-linux.test.ts new file mode 100644 index 00000000000..54806c21cca --- /dev/null +++ b/tests/service/service-desktop-startup-linux.test.ts @@ -0,0 +1,125 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { deriveStartupHealth, startupHealthSummary } from "../../src/codex/autostart-health"; +import { desktopStartupOwnership, diagnoseDesktopStartup, diagnoseLinuxDesktopStartup } from "../../src/service/desktop-startup"; +import type { ServiceOwnershipResolution } from "../../src/service/state"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +function fixture() { + const home = mkdtempSync(join(tmpdir(), "ocx-desktop-startup-linux-")); + roots.push(home); + const app = join(home, "usr", "bin", "opencodex-desktop"); + const proxy = join(home, "usr", "bin", "ocx"); + const idPath = join(home, ".config", "com.opencodex.desktop", "install-id"); + const entryPath = join(home, ".config", "autostart", "OpenCodex.desktop"); + for (const path of [app, proxy, idPath]) { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, path === idPath ? "installation-a\n" : "fixture"); + chmodSync(path, 0o700); + } + mkdirSync(join(entryPath, ".."), { recursive: true }); + const entry = (exec: string, extra = "") => writeFileSync(entryPath, + `[Desktop Entry]\nType=Application\nVersion=1.0\nName=OpenCodex\nExec=${exec}\nStartupNotify=false\nTerminal=false${extra}`); + entry(`${app} --autostart`); + const owner: ServiceOwnershipResolution = { kind: "owned", revision: 1, + ownership: { owner: "desktop", installId: "installation-a", consentGeneration: 2 } }; + const state = { + owner, exe: { 100: proxy, 200: app } as Record, parent: { 100: 200, 200: 1 } as Record, + pid: 100 as number | null, ownerReads: 0, pidReads: 0, changedPid: false, changedOwner: false, + }; + const deps = { + platform: "linux" as const, home, env: {} as NodeJS.ProcessEnv, + ownership: (): ServiceOwnershipResolution => { + state.ownerReads++; + return state.changedOwner && state.ownerReads > 1 ? { kind: "none", revision: 2 } : state.owner; + }, + readPid: () => { state.pidReads++; return state.changedPid && state.pidReads > 1 ? 101 : state.pid; }, + proc: { + exe: (pid: number) => { const exe = state.exe[pid]; if (!exe) throw new Error("no such process"); return exe; }, + parent: (pid: number) => { const parent = state.parent[pid]; if (parent === undefined) throw new Error("no such process"); return parent; }, + }, + }; + return { state, deps, idPath, entryPath, entry, proxy, app, home }; +} + +const healthBase = { + routingKind: "opencodex-local" as const, platform: "linux" as const, + autostartEnabled: true, serviceInstalled: true, serviceViable: false, + serviceEnabled: true, serviceRunning: false, serviceStale: false, + serviceConflict: false, serviceSupported: true, shimInstalled: false, shimHealthy: false, +}; + +test("matching install, XDG login entry and the app supervising its sidecar grant desktop protection on Linux", () => { + const { deps } = fixture(); + const desktop = diagnoseLinuxDesktopStartup(deps); + expect(desktop).toEqual({ owned: true, loginEnabled: true, running: true, viable: true }); + expect(diagnoseDesktopStartup(deps)).toEqual(desktop); + const health = deriveStartupHealth({ ...healthBase, desktop }); + expect(health).toMatchObject({ status: "protected", rebootSafe: true, protection: "desktop", recommendedCommand: null }); + expect(startupHealthSummary(health)).toBe("protected by desktop app at login and its proxy supervisor"); +}); + +test("XDG_CONFIG_HOME relocates the install id and login entry", () => { + const f = fixture(); + const moved = join(f.home, "xdg"); + mkdirSync(join(moved, "com.opencodex.desktop"), { recursive: true }); + mkdirSync(join(moved, "autostart"), { recursive: true }); + writeFileSync(join(moved, "com.opencodex.desktop", "install-id"), "installation-a"); + expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: moved } })).toMatchObject({ owned: true, loginEnabled: false, viable: false }); + writeFileSync(join(moved, "autostart", "OpenCodex.desktop"), `[Desktop Entry]\nType=Application\nExec="${f.app}" --autostart\n`); + expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: moved } })).toMatchObject({ viable: true }); +}); + +test("failed identity, login entry or process evidence retains the durable desktop claim", () => { + type F = ReturnType; + const mutations: ((f: F) => void)[] = [ + f => writeFileSync(f.idPath, "different-install"), + f => rmSync(f.idPath), + f => rmSync(f.entryPath), + f => f.entry(`${f.app} --autostart`, "\nHidden=true"), + f => f.entry(`${f.app} --autostart`, "\nX-GNOME-Autostart-enabled=false"), + f => f.entry(f.app), + f => f.entry(`${f.app} --wrong`), + f => f.entry(`${f.proxy} --autostart`), + f => f.entry(`opencodex-desktop --autostart`), + f => { f.state.exe[100] = f.app; }, + f => { f.state.parent[100] = 1; }, + f => { f.state.exe[200] = f.proxy; }, + f => { delete f.state.exe[100]; }, + f => { f.state.pid = null; }, + f => { f.state.changedPid = true; }, + f => { f.state.changedOwner = true; }, + ]; + for (const mutate of mutations) { + const f = fixture(); mutate(f); + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, viable: false }); + } +}); + +test("Linux desktop ownership never recommends the service it superseded", () => { + const f = fixture(); + expect(desktopStartupOwnership(f.deps)).toEqual({ owned: true, loginEnabled: false, running: false, viable: false }); + f.state.pid = null; + const health = deriveStartupHealth({ ...healthBase, desktop: diagnoseLinuxDesktopStartup(f.deps) }); + expect(health).toMatchObject({ status: "at-risk", protection: "none", recommendedCommand: null }); + expect(startupHealthSummary(health)).toContain("Start at Login"); + expect(startupHealthSummary(health)).not.toContain("ocx service"); +}); + +test("other platforms and absent, CLI or unknown ownership cannot grant Linux desktop protection", () => { + const f = fixture(); + expect(diagnoseLinuxDesktopStartup({ ...f.deps, platform: "win32" })).toBeUndefined(); + expect(f.state.ownerReads).toBe(0); + for (const owner of [ + { kind: "none", revision: 0 }, { kind: "unknown", reason: "unreadable" }, + { kind: "owned", revision: 1, ownership: { owner: "cli", installId: "installation-a", consentGeneration: 1 } }, + ] as ServiceOwnershipResolution[]) { + f.state.owner = owner; + expect(diagnoseLinuxDesktopStartup(f.deps)).toBeUndefined(); + expect(f.state.pidReads).toBe(0); + } +}); From a1b7e474fd98df7978752ed88a5daf0e28a881eb Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 21:38:48 +0900 Subject: [PATCH 2/5] fix(service): validate Linux desktop startup evidence conservatively --- .../src/content/docs/guides/desktop-app.md | 18 ++- scripts/test-layout/layout.json | 4 +- src/service/desktop-startup.ts | 64 ++++++---- structure/runtime.md | 2 +- .../service-desktop-startup-linux.test.ts | 112 +++++++++++++++++- 5 files changed, 164 insertions(+), 36 deletions(-) diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 85f7083f453..fd056828e8b 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -75,9 +75,21 @@ or Task Manager remain disabled. 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 login registration is the XDG autostart entry -`~/.config/autostart/OpenCodex.desktop` (under `$XDG_CONFIG_HOME` when set), and it counts only -while it is not marked `Hidden=true` or `X-GNOME-Autostart-enabled=false`. +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 HOME-based entry; the desktop install-id separately follows +`$XDG_CONFIG_HOME` (or `~/.config` by default). +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; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 73af19ab882..0b5378fd7f2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -73,9 +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-linux.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", diff --git a/src/service/desktop-startup.ts b/src/service/desktop-startup.ts index f321c82e1b4..8fe0271eba4 100644 --- a/src/service/desktop-startup.ts +++ b/src/service/desktop-startup.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import { accessSync, constants, readFileSync, readlinkSync, realpathSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join } from "node:path"; +import { basename, dirname, isAbsolute, join } from "node:path"; import { readPid } from "../config/process-state"; import { resolveServiceOwnership, sameServiceOwnershipSubject, type ServiceOwnershipResolution } from "./state"; @@ -109,7 +109,7 @@ export function diagnoseMacDesktopStartup(deps: DesktopStartupDeps = {}): Deskto } } -/** XDG autostart entry written by the desktop app's login item (`tauri-plugin-autostart`). */ +/** 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(); let section = ""; @@ -123,13 +123,14 @@ function linuxLoginApp(entry: string): string | null { } } if (fields.get("Type") !== "Application" || fields.get("Hidden") === "true" - || fields.get("X-GNOME-Autostart-enabled") === "false") return null; - const exec = /^(?:"([^"]+)"|(\S+)) --autostart$/.exec(fields.get("Exec") ?? ""); - const app = exec?.[1] ?? exec?.[2]; - return app && isAbsolute(app) && app.endsWith("/opencodex-desktop") ? app : null; + || 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, XDG login entry and live parent/child checks via procfs. */ +/** 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; @@ -140,25 +141,40 @@ export function diagnoseLinuxDesktopStartup(deps: DesktopStartupDeps = {}): Desk 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(deps.home ?? homedir(), ".config"); - const id = readFileSync(join(config, "com.opencodex.desktop", "install-id"), "utf8").trim(); - if (id !== owner.ownership.installId) return deriveDesktopStartup(facts); - const login = linuxLoginApp(readFileSync(join(config, "autostart", "OpenCodex.desktop"), "utf8")); - if (!login) return deriveDesktopStartup(facts); - const app = realpathSync(login); - const proxy = realpathSync(join(dirname(app), "ocx")); - accessSync(app, constants.X_OK); - accessSync(proxy, constants.X_OK); - facts.loginEnabled = true; - const pid = pidReader(); - if (pid !== null) { - const parent = proc.parent(pid); - facts.running = realpathSync(proc.exe(pid)) === proxy && parent > 1 - && realpathSync(proc.exe(parent)) === app && pidReader() === pid; + const config = configured && isAbsolute(configured) ? configured : join(home, ".config"); + // auto-launch 0.5.0 uses HOME for autostart; Tauri app_config_dir uses XDG for install-id. + 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; } - const currentOwner = ownership(); - if (currentOwner.kind === "unknown" || !sameServiceOwnershipSubject(owner, currentOwner)) facts.running = false; return deriveDesktopStartup(facts); } catch { // Unreadable login or procfs evidence never grants protection or releases the claim. diff --git a/structure/runtime.md b/structure/runtime.md index f3492429d02..d6e85be9dc8 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 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; 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, install-id follows XDG_CONFIG_HOME while auto-launch 0.5.0 writes the login entry under HOME/.config/autostart. 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 diff --git a/tests/service/service-desktop-startup-linux.test.ts b/tests/service/service-desktop-startup-linux.test.ts index 54806c21cca..c9113127768 100644 --- a/tests/service/service-desktop-startup-linux.test.ts +++ b/tests/service/service-desktop-startup-linux.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { deriveStartupHealth, startupHealthSummary } from "../../src/codex/autostart-health"; @@ -63,17 +63,119 @@ test("matching install, XDG login entry and the app supervising its sidecar gran expect(startupHealthSummary(health)).toBe("protected by desktop app at login and its proxy supervisor"); }); -test("XDG_CONFIG_HOME relocates the install id and login entry", () => { +test("XDG_CONFIG_HOME relocates only the install id; autostart remains under HOME", () => { const f = fixture(); const moved = join(f.home, "xdg"); mkdirSync(join(moved, "com.opencodex.desktop"), { recursive: true }); - mkdirSync(join(moved, "autostart"), { recursive: true }); writeFileSync(join(moved, "com.opencodex.desktop", "install-id"), "installation-a"); - expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: moved } })).toMatchObject({ owned: true, loginEnabled: false, viable: false }); - writeFileSync(join(moved, "autostart", "OpenCodex.desktop"), `[Desktop Entry]\nType=Application\nExec="${f.app}" --autostart\n`); + writeFileSync(f.idPath, "stale-default-install"); expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: moved } })).toMatchObject({ viable: true }); + mkdirSync(join(moved, "autostart"), { recursive: true }); + writeFileSync(join(moved, "autostart", "OpenCodex.desktop"), `[Desktop Entry]\nType=Application\nExec=${f.app} --autostart\n`); + rmSync(f.entryPath); + expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: moved } })).toMatchObject({ viable: false }); +}); + +for (const field of ["OnlyShowIn=GNOME;", "NotShowIn=GNOME;", "TryExec=/missing", "OnlyShowIn=", "NotShowIn=", "TryExec="]) { + test(`conditional autostart entry is not credited: ${field}`, () => { + const f = fixture(); f.entry(`${f.app} --autostart`, `\n${field}`); + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, loginEnabled: false, viable: false }); + }); +} + +for (const command of [ + (app: string) => `"${app}" --autostart`, + (app: string) => `'${app}' --autostart`, + (app: string) => `${app.replace("/usr/", "/%Z/usr/")} --autostart`, + (app: string) => `${app.replace("/usr/", "/%f/usr/")} --autostart`, + (app: string) => `${app.replace("/usr/", "/escaped\\/usr/")} --autostart`, + (app: string) => `${app.replace("/usr/", "/has space/usr/")} --autostart`, + (app: string) => `${app} --autostart extra`, +]) { + test(`ambiguous Exec is not credited: ${command("/usr/opencodex-desktop")}`, () => { + const f = fixture(); + // Make the literal path real so rejection tests parsing, not a missing executable. + const exec = command(f.app); + const literal = exec.slice(0, exec.indexOf(" --autostart")).replace(/^["']|["']$/g, ""); + mkdirSync(join(literal, ".."), { recursive: true }); + writeFileSync(literal, "fixture"); chmodSync(literal, 0o700); + if (literal !== f.app) { + const proxy = join(literal, "..", "ocx"); + writeFileSync(proxy, "fixture"); chmodSync(proxy, 0o700); + f.state.exe[100] = proxy; f.state.exe[200] = literal; + } + f.entry(exec); + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, loginEnabled: false, viable: false }); + }); +} + +test("an opencodex-desktop symlink to another executable cannot grant protection", () => { + const f = fixture(); + const target = join(f.home, "usr", "bin", "sh"); + writeFileSync(target, "fixture"); chmodSync(target, 0o700); + rmSync(f.app); symlinkSync(target, f.app); + f.state.exe[200] = target; + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, loginEnabled: false, viable: false }); }); +test("a desktop symlink uses the ocx beside its resolved executable", () => { + const f = fixture(); + const alias = join(f.home, "alias", "opencodex-desktop"); + mkdirSync(join(alias, "..")); symlinkSync(f.app, alias); f.entry(`${alias} --autostart`); + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ viable: true }); + const outside = join(f.home, "outside-ocx"); + writeFileSync(outside, "fixture"); chmodSync(outside, 0o700); + rmSync(f.proxy); symlinkSync(outside, f.proxy); f.state.exe[100] = outside; + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, viable: false }); +}); + +test("AppImage registration cannot credit the executable running inside its mount", () => { + const f = fixture(); + const image = join(f.home, "OpenCodex.AppImage"); + writeFileSync(image, "fixture"); chmodSync(image, 0o700); f.entry(`${image} --autostart`); + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, loginEnabled: false, viable: false }); +}); + +type F = ReturnType; +const changes: [string, (f: F) => void][] = [ + ["install-id", f => writeFileSync(f.idPath, "different-install")], + ["Hidden=true", f => f.entry(`${f.app} --autostart`, "\nHidden=true")], + ["valid entry contents", f => f.entry(`${f.app} --autostart`, "\n# rewritten")], + ["desktop executable", f => { + const target = join(f.home, "replacement", "opencodex-desktop"); + mkdirSync(join(target, "..")); writeFileSync(target, "fixture"); chmodSync(target, 0o700); + const proxy = join(target, "..", "ocx"); writeFileSync(proxy, "fixture"); chmodSync(proxy, 0o700); + rmSync(f.app); symlinkSync(target, f.app); f.state.exe[200] = target; f.state.exe[100] = proxy; + }], + ["proxy executable", f => { + const target = join(f.home, "replacement-ocx"); + writeFileSync(target, "fixture"); chmodSync(target, 0o700); + rmSync(f.proxy); symlinkSync(target, f.proxy); f.state.exe[100] = target; + }], + ["executable permission", f => chmodSync(f.app, 0o600)], + ["proxy permission", f => chmodSync(f.proxy, 0o600)], + ["PID", f => { f.state.pid = 101; }], + ["parent relationship", f => { f.state.parent[100] = 300; f.state.exe[300] = f.app; }], + ["child process executable", f => { f.state.exe[100] = f.app; }], + ["parent process executable", f => { f.state.exe[200] = f.proxy; }], + ["ownership", f => { f.state.owner = { kind: "none", revision: 2 }; }], +]; +for (const [name, mutate] of changes) { + test(`final evidence read fails closed after ${name} changes`, () => { + const f = fixture(); + const original = f.deps.ownership; + let changed = false; + f.deps.ownership = () => { + const result = original(); + // Change evidence after the first process/PID checks, immediately before the final read. + if (f.state.ownerReads === 2 && !changed) { changed = true; mutate(f); } + return result; + }; + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, viable: false }); + expect(changed).toBe(true); + }); +} + test("failed identity, login entry or process evidence retains the durable desktop claim", () => { type F = ReturnType; const mutations: ((f: F) => void)[] = [ From 81431ea4baffcdac9c0dd8a077515d89e0aed4f9 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 21:45:00 +0900 Subject: [PATCH 3/5] fix(service): leave a relocated XDG_CONFIG_HOME uncredited for Linux desktop startup auto-launch 0.5.0 writes the login entry under ~/.config/autostart. When XDG_CONFIG_HOME points elsewhere the session searches another autostart directory, so that entry proves registration but not login startup. --- docs-site/src/content/docs/guides/desktop-app.md | 5 +++-- src/service/desktop-startup.ts | 4 +++- structure/ops/service-and-sidecars.md | 2 +- structure/runtime.md | 2 +- tests/service/service-desktop-startup-linux.test.ts | 12 +++++++----- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index fd056828e8b..d6e437216a7 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -77,8 +77,9 @@ Startup safety reports **Desktop app** protection when OpenCodex's recorded owne **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 HOME-based entry; the desktop install-id separately follows -`$XDG_CONFIG_HOME` (or `~/.config` by default). +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 diff --git a/src/service/desktop-startup.ts b/src/service/desktop-startup.ts index 8fe0271eba4..cd782fbb08a 100644 --- a/src/service/desktop-startup.ts +++ b/src/service/desktop-startup.ts @@ -144,7 +144,9 @@ export function diagnoseLinuxDesktopStartup(deps: DesktopStartupDeps = {}): Desk 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 uses HOME for autostart; Tauri app_config_dir uses XDG for install-id. + // 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 (config !== join(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; diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 2c4f84e9f8b..8b46f9d9c63 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -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. -Desktop startup diagnostics use `src/service/desktop-startup.ts` to read the ownership record, the login registration (macOS: launchd; Linux: the `$XDG_CONFIG_HOME/autostart/OpenCodex.desktop` entry, which must launch an absolute `opencodex-desktop` with `--autostart` and not be hidden or disabled) and exact parent/child executable paths (Linux: `/proc//exe` and the parent pid from `/proc//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. 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//exe` and the parent pid from `/proc//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`). diff --git a/structure/runtime.md b/structure/runtime.md index d6e85be9dc8..b10e4fe9110 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 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, install-id follows XDG_CONFIG_HOME while auto-launch 0.5.0 writes the login entry under HOME/.config/autostart. 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. +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 diff --git a/tests/service/service-desktop-startup-linux.test.ts b/tests/service/service-desktop-startup-linux.test.ts index c9113127768..3ae84c9520c 100644 --- a/tests/service/service-desktop-startup-linux.test.ts +++ b/tests/service/service-desktop-startup-linux.test.ts @@ -63,17 +63,19 @@ test("matching install, XDG login entry and the app supervising its sidecar gran expect(startupHealthSummary(health)).toBe("protected by desktop app at login and its proxy supervisor"); }); -test("XDG_CONFIG_HOME relocates only the install id; autostart remains under HOME", () => { +test("a relocated XDG_CONFIG_HOME is not credited: the HOME-based entry is outside its autostart path", () => { const f = fixture(); const moved = join(f.home, "xdg"); mkdirSync(join(moved, "com.opencodex.desktop"), { recursive: true }); writeFileSync(join(moved, "com.opencodex.desktop", "install-id"), "installation-a"); - writeFileSync(f.idPath, "stale-default-install"); - expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: moved } })).toMatchObject({ viable: true }); + expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: moved } })).toMatchObject({ viable: false }); mkdirSync(join(moved, "autostart"), { recursive: true }); - writeFileSync(join(moved, "autostart", "OpenCodex.desktop"), `[Desktop Entry]\nType=Application\nExec=${f.app} --autostart\n`); - rmSync(f.entryPath); + writeFileSync(join(moved, "autostart", "OpenCodex.desktop"), `[Desktop Entry] +Type=Application +Exec=${f.app} --autostart +`); expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: moved } })).toMatchObject({ viable: false }); + expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: join(f.home, ".config") } })).toMatchObject({ viable: true }); }); for (const field of ["OnlyShowIn=GNOME;", "NotShowIn=GNOME;", "TryExec=/missing", "OnlyShowIn=", "NotShowIn=", "TryExec="]) { From 4d89c6a3cfb1a4b5a05a9e8e3394c9ba1e00c749 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 22:30:43 +0900 Subject: [PATCH 4/5] test(service): run the Linux desktop startup fixtures only off Windows The fixtures build POSIX paths with execute bits and symlinks, and the diagnostic itself only runs on Linux, so the Windows shard cannot exercise it. --- .../service-desktop-startup-linux.test.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/tests/service/service-desktop-startup-linux.test.ts b/tests/service/service-desktop-startup-linux.test.ts index 3ae84c9520c..dfb157a1aab 100644 --- a/tests/service/service-desktop-startup-linux.test.ts +++ b/tests/service/service-desktop-startup-linux.test.ts @@ -6,6 +6,8 @@ import { deriveStartupHealth, startupHealthSummary } from "../../src/codex/autos import { desktopStartupOwnership, diagnoseDesktopStartup, diagnoseLinuxDesktopStartup } from "../../src/service/desktop-startup"; import type { ServiceOwnershipResolution } from "../../src/service/state"; +// Fixtures use POSIX paths, execute bits and symlinks, and the diagnostic only runs on Linux. +const linuxTest = test.skipIf(process.platform === "win32"); const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -53,7 +55,7 @@ const healthBase = { serviceConflict: false, serviceSupported: true, shimInstalled: false, shimHealthy: false, }; -test("matching install, XDG login entry and the app supervising its sidecar grant desktop protection on Linux", () => { +linuxTest("matching install, XDG login entry and the app supervising its sidecar grant desktop protection on Linux", () => { const { deps } = fixture(); const desktop = diagnoseLinuxDesktopStartup(deps); expect(desktop).toEqual({ owned: true, loginEnabled: true, running: true, viable: true }); @@ -63,7 +65,7 @@ test("matching install, XDG login entry and the app supervising its sidecar gran expect(startupHealthSummary(health)).toBe("protected by desktop app at login and its proxy supervisor"); }); -test("a relocated XDG_CONFIG_HOME is not credited: the HOME-based entry is outside its autostart path", () => { +linuxTest("a relocated XDG_CONFIG_HOME is not credited: the HOME-based entry is outside its autostart path", () => { const f = fixture(); const moved = join(f.home, "xdg"); mkdirSync(join(moved, "com.opencodex.desktop"), { recursive: true }); @@ -79,7 +81,7 @@ Exec=${f.app} --autostart }); for (const field of ["OnlyShowIn=GNOME;", "NotShowIn=GNOME;", "TryExec=/missing", "OnlyShowIn=", "NotShowIn=", "TryExec="]) { - test(`conditional autostart entry is not credited: ${field}`, () => { + linuxTest(`conditional autostart entry is not credited: ${field}`, () => { const f = fixture(); f.entry(`${f.app} --autostart`, `\n${field}`); expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, loginEnabled: false, viable: false }); }); @@ -94,7 +96,7 @@ for (const command of [ (app: string) => `${app.replace("/usr/", "/has space/usr/")} --autostart`, (app: string) => `${app} --autostart extra`, ]) { - test(`ambiguous Exec is not credited: ${command("/usr/opencodex-desktop")}`, () => { + linuxTest(`ambiguous Exec is not credited: ${command("/usr/opencodex-desktop")}`, () => { const f = fixture(); // Make the literal path real so rejection tests parsing, not a missing executable. const exec = command(f.app); @@ -111,7 +113,7 @@ for (const command of [ }); } -test("an opencodex-desktop symlink to another executable cannot grant protection", () => { +linuxTest("an opencodex-desktop symlink to another executable cannot grant protection", () => { const f = fixture(); const target = join(f.home, "usr", "bin", "sh"); writeFileSync(target, "fixture"); chmodSync(target, 0o700); @@ -120,7 +122,7 @@ test("an opencodex-desktop symlink to another executable cannot grant protection expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, loginEnabled: false, viable: false }); }); -test("a desktop symlink uses the ocx beside its resolved executable", () => { +linuxTest("a desktop symlink uses the ocx beside its resolved executable", () => { const f = fixture(); const alias = join(f.home, "alias", "opencodex-desktop"); mkdirSync(join(alias, "..")); symlinkSync(f.app, alias); f.entry(`${alias} --autostart`); @@ -131,7 +133,7 @@ test("a desktop symlink uses the ocx beside its resolved executable", () => { expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, viable: false }); }); -test("AppImage registration cannot credit the executable running inside its mount", () => { +linuxTest("AppImage registration cannot credit the executable running inside its mount", () => { const f = fixture(); const image = join(f.home, "OpenCodex.AppImage"); writeFileSync(image, "fixture"); chmodSync(image, 0o700); f.entry(`${image} --autostart`); @@ -163,7 +165,7 @@ const changes: [string, (f: F) => void][] = [ ["ownership", f => { f.state.owner = { kind: "none", revision: 2 }; }], ]; for (const [name, mutate] of changes) { - test(`final evidence read fails closed after ${name} changes`, () => { + linuxTest(`final evidence read fails closed after ${name} changes`, () => { const f = fixture(); const original = f.deps.ownership; let changed = false; @@ -178,7 +180,7 @@ for (const [name, mutate] of changes) { }); } -test("failed identity, login entry or process evidence retains the durable desktop claim", () => { +linuxTest("failed identity, login entry or process evidence retains the durable desktop claim", () => { type F = ReturnType; const mutations: ((f: F) => void)[] = [ f => writeFileSync(f.idPath, "different-install"), @@ -204,7 +206,7 @@ test("failed identity, login entry or process evidence retains the durable deskt } }); -test("Linux desktop ownership never recommends the service it superseded", () => { +linuxTest("Linux desktop ownership never recommends the service it superseded", () => { const f = fixture(); expect(desktopStartupOwnership(f.deps)).toEqual({ owned: true, loginEnabled: false, running: false, viable: false }); f.state.pid = null; @@ -214,7 +216,7 @@ test("Linux desktop ownership never recommends the service it superseded", () => expect(startupHealthSummary(health)).not.toContain("ocx service"); }); -test("other platforms and absent, CLI or unknown ownership cannot grant Linux desktop protection", () => { +linuxTest("other platforms and absent, CLI or unknown ownership cannot grant Linux desktop protection", () => { const f = fixture(); expect(diagnoseLinuxDesktopStartup({ ...f.deps, platform: "win32" })).toBeUndefined(); expect(f.state.ownerReads).toBe(0); From 3b9659021a38e6b89fa65ef444f917921d95b48d Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 22:46:56 +0900 Subject: [PATCH 5/5] fix(service): compare XDG_CONFIG_HOME to ~/.config after path resolution --- src/service/desktop-startup.ts | 4 ++-- tests/service/service-desktop-startup-linux.test.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/service/desktop-startup.ts b/src/service/desktop-startup.ts index cd782fbb08a..e211ff9bf95 100644 --- a/src/service/desktop-startup.ts +++ b/src/service/desktop-startup.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import { accessSync, constants, readFileSync, readlinkSync, realpathSync } from "node:fs"; import { homedir } from "node:os"; -import { basename, dirname, isAbsolute, 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"; @@ -146,7 +146,7 @@ export function diagnoseLinuxDesktopStartup(deps: DesktopStartupDeps = {}): Desk 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 (config !== join(home, ".config")) return deriveDesktopStartup(facts); + 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; diff --git a/tests/service/service-desktop-startup-linux.test.ts b/tests/service/service-desktop-startup-linux.test.ts index dfb157a1aab..fd942cbf8f0 100644 --- a/tests/service/service-desktop-startup-linux.test.ts +++ b/tests/service/service-desktop-startup-linux.test.ts @@ -78,6 +78,7 @@ Exec=${f.app} --autostart `); expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: moved } })).toMatchObject({ viable: false }); expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: join(f.home, ".config") } })).toMatchObject({ viable: true }); + expect(diagnoseLinuxDesktopStartup({ ...f.deps, env: { XDG_CONFIG_HOME: `${join(f.home, ".config")}/` } })).toMatchObject({ viable: true }); }); for (const field of ["OnlyShowIn=GNOME;", "NotShowIn=GNOME;", "TryExec=/missing", "OnlyShowIn=", "NotShowIn=", "TryExec="]) {