diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 4d25d6f664b..d6e437216a7 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -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; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f331f7c3c5c..0b5378fd7f2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -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", 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..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, 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"; @@ -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,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(); + 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); +} 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..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. -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//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 a4ba9577f82..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 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 diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 71e304011d0..92e347d72a3 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..fd942cbf8f0 --- /dev/null +++ b/tests/service/service-desktop-startup-linux.test.ts @@ -0,0 +1,232 @@ +import { afterEach, expect, test } from "bun:test"; +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"; +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 }); }); + +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, +}; + +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 }); + 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"); +}); + +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 }); + writeFileSync(join(moved, "com.opencodex.desktop", "install-id"), "installation-a"); + 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] +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 }); + 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="]) { + 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 }); + }); +} + +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`, +]) { + 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); + 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 }); + }); +} + +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); + rmSync(f.app); symlinkSync(target, f.app); + f.state.exe[200] = target; + expect(diagnoseLinuxDesktopStartup(f.deps)).toMatchObject({ owned: true, loginEnabled: false, viable: false }); +}); + +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`); + 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 }); +}); + +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`); + 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) { + linuxTest(`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); + }); +} + +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"), + 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 }); + } +}); + +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; + 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"); +}); + +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); + 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); + } +});