@bitkyc08/opencodex 2.53.0-preview.20260913 → 2.54.0-preview.20260914

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/gui/dist/assets/{index-D7ynYo2K.js → index-B4VYfZcY.js} +2 -2
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/devin.ts +39 -7
  5. package/src/cli/capabilities.ts +4 -2
  6. package/src/cli/catalog.ts +39 -10
  7. package/src/cli/dispatch.ts +19 -64
  8. package/src/cli/doctor.ts +1 -1
  9. package/src/cli/internal-command.ts +44 -0
  10. package/src/cli/registry.ts +9 -7
  11. package/src/cli/restart-scope.ts +184 -0
  12. package/src/codex/app-server-processes.ts +20 -5
  13. package/src/codex/app-server-restart-service.ts +29 -0
  14. package/src/codex/catalog/provider-fetch.ts +13 -5
  15. package/src/codex/catalog/sync.ts +12 -3
  16. package/src/codex/desktop-app/darwin.ts +268 -0
  17. package/src/codex/desktop-app/handoff.ts +303 -0
  18. package/src/codex/desktop-app/linux.ts +388 -0
  19. package/src/codex/desktop-app/lock.ts +226 -0
  20. package/src/codex/desktop-app/types.ts +141 -0
  21. package/src/codex/desktop-app/windows.ts +239 -0
  22. package/src/codex/desktop-app-restart.ts +264 -279
  23. package/src/codex/inject.ts +83 -21
  24. package/src/codex/sync.ts +16 -22
  25. package/src/generated/compatibility-version.json +58 -22
  26. package/src/lib/codex-restart-contract.ts +31 -0
  27. package/src/providers/registry.ts +13 -12
  28. package/src/server/responses/core.ts +62 -4
  29. package/src/server/responses/encrypted-payload.ts +161 -0
  30. package/src/server/responses.ts +1 -1
  31. package/src/types/provider.ts +6 -5
  32. package/src/web-search/index.ts +14 -67
  33. package/src/web-search/passthrough-bridge.ts +256 -32
  34. package/src/web-search/sidecar-providers.ts +76 -0
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Restarting the Codex app you are running inside.
3
+ *
4
+ * The self-ancestry guard is correct to refuse a direct restart: terminating your own
5
+ * tree kills the command mid-flight and leaves the operator with neither a restarted
6
+ * app nor an explanation. But on a developer machine that refusal fires in the normal
7
+ * case, not a corner case - the measured shell is
8
+ * `zsh -> bundled codex app-server -> ChatGPT -> launchd`, so anything run from a Codex
9
+ * terminal or agent session is inside the tree. Without a handoff, the merged
10
+ * `--restart-codex` would refuse in exactly the situation that produced the original
11
+ * "it does nothing" report.
12
+ *
13
+ * So the refusal becomes a handoff: a detached helper outlives the caller, waits for it
14
+ * to exit, re-enumerates, and performs the restart from outside the tree.
15
+ *
16
+ * Two things make the helper safe to kill the app around:
17
+ *
18
+ * - It waits for the calling process to exit first. At that moment it is orphaned and
19
+ * reparented, so it is no longer reachable by a tree walk from the app root. This
20
+ * matters most on Windows, where `taskkill /T` follows live parent links and never
21
+ * reparents orphans.
22
+ * - It re-runs the ancestry check itself rather than trusting the caller's finding, and
23
+ * it passes `allowHandoff: false` so it can only ever take the direct path or refuse.
24
+ * Recursion is structurally impossible rather than merely unlikely.
25
+ *
26
+ * Design: devlog/_plan/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md
27
+ */
28
+ import { spawn } from "node:child_process";
29
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs";
30
+ import { basename, dirname, join, resolve } from "node:path";
31
+ import { getConfigDir } from "../../config/paths";
32
+ import {
33
+ readDesktopRestartLockOwner,
34
+ releaseDesktopRestartLock,
35
+ transferDesktopRestartLock,
36
+ type DesktopRestartLockIo,
37
+ } from "./lock";
38
+
39
+ /** How long the helper waits for its caller to exit before giving up. */
40
+ const CALLER_EXIT_TIMEOUT_MS = 20_000;
41
+ const CALLER_POLL_MS = 100;
42
+ /** A plan older than this is not ours to run. */
43
+ const PLAN_MAX_AGE_MS = 5 * 60_000;
44
+
45
+ export interface DesktopRestartHandoffPlan {
46
+ schemaVersion: 1;
47
+ /** Pid the helper waits on before acting. */
48
+ callerPid: number;
49
+ createdAtMs: number;
50
+ }
51
+
52
+ export type HandoffStartOutcome =
53
+ | { kind: "started"; helperPid: number; logPath: string }
54
+ | { kind: "failed"; reason: "no_executable" | "plan_write_failed" | "spawn_failed" | "lock_transfer_failed" };
55
+
56
+ export interface HandoffIo {
57
+ now?: () => number;
58
+ pid?: number;
59
+ execPath?: string;
60
+ argv?: readonly string[];
61
+ homeDir?: string;
62
+ lock?: DesktopRestartLockIo;
63
+ spawnHelper?: (command: string, args: readonly string[]) => { pid?: number | undefined; unref(): void };
64
+ isAlive?: (pid: number) => boolean;
65
+ sleep?: (ms: number) => void;
66
+ }
67
+
68
+ export function handoffLogPath(io: HandoffIo = {}): string {
69
+ return join(io.homeDir ?? getConfigDir(), "desktop-restart-handoff.log");
70
+ }
71
+
72
+ /**
73
+ * How to re-invoke this CLI as the helper.
74
+ *
75
+ * `process.execPath` alone is not enough, because it differs between running from a
76
+ * checkout, through the installed npm shim, and as a packaged binary. Resolution is
77
+ * explicit and a failure to resolve is a REFUSAL rather than a guess: spawning the
78
+ * wrong interpreter with a path that does not exist produces a helper that exits
79
+ * immediately and an operator who was told the restart was handed off.
80
+ */
81
+ export function resolveHelperCommand(io: HandoffIo = {}): { command: string; args: string[] } | null {
82
+ const execPath = io.execPath ?? process.execPath;
83
+ const argv = io.argv ?? process.argv;
84
+ const entry = argv[1];
85
+ if (entry && existsSync(entry)) return { command: execPath, args: [entry] };
86
+ if (basename(execPath).replace(/\.exe$/i, "") === "ocx") return { command: execPath, args: [] };
87
+ return null;
88
+ }
89
+
90
+ function writePlan(path: string, plan: DesktopRestartHandoffPlan): boolean {
91
+ try {
92
+ mkdirSync(dirname(path), { recursive: true });
93
+ // Exclusive create: the path is handed to another process, so it must not be
94
+ // possible to hand over a file somebody else authored.
95
+ const fd = openSync(path, "wx", 0o600);
96
+ try {
97
+ writeSync(fd, JSON.stringify(plan));
98
+ } finally {
99
+ closeSync(fd);
100
+ }
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ export function startDesktopRestartHandoff(io: HandoffIo = {}): HandoffStartOutcome {
108
+ const resolved = resolveHelperCommand(io);
109
+ if (!resolved) return { kind: "failed", reason: "no_executable" };
110
+
111
+ const now = io.now ?? Date.now;
112
+ const callerPid = io.pid ?? process.pid;
113
+ const home = io.homeDir ?? getConfigDir();
114
+ const planPath = join(home, `desktop-restart-handoff-${callerPid}-${Math.random().toString(36).slice(2)}.json`);
115
+ const plan: DesktopRestartHandoffPlan = { schemaVersion: 1, callerPid, createdAtMs: now() };
116
+ if (!writePlan(planPath, plan)) return { kind: "failed", reason: "plan_write_failed" };
117
+
118
+ const args = [...resolved.args, "internal", "desktop-restart-handoff", "--plan", planPath];
119
+ let child: { pid?: number | undefined; unref(): void };
120
+ try {
121
+ child = (io.spawnHelper ?? defaultSpawnHelper)(resolved.command, args);
122
+ } catch {
123
+ try { unlinkSync(planPath); } catch { /* best effort */ }
124
+ return { kind: "failed", reason: "spawn_failed" };
125
+ }
126
+ if (child.pid === undefined) {
127
+ // A detached child reports a failed launch asynchronously, to a parent that is about
128
+ // to exit. An absent pid is the only synchronous evidence the spawn happened.
129
+ try { unlinkSync(planPath); } catch { /* best effort */ }
130
+ return { kind: "failed", reason: "spawn_failed" };
131
+ }
132
+ child.unref();
133
+
134
+ // Hand the lock over only AFTER a successful spawn. Doing it earlier would strand the
135
+ // lock on a pid that never came into being, and the next restart would have to wait
136
+ // out the staleness window for nothing.
137
+ //
138
+ // A FAILED transfer is not cosmetic. The lock would still name this process, which is
139
+ // about to exit, so it reads as stale for the whole helper wait and a concurrent
140
+ // restart could reclaim it and run a second ladder - the dual-kill the lock exists to
141
+ // prevent. Reporting failure here is safe because the helper independently refuses to
142
+ // act unless the lock names IT, so the spawned process becomes a no-op rather than an
143
+ // unsupervised restart.
144
+ if (!transferDesktopRestartLock(child.pid, io.lock)) {
145
+ return { kind: "failed", reason: "lock_transfer_failed" };
146
+ }
147
+ return { kind: "started", helperPid: child.pid, logPath: handoffLogPath(io) };
148
+ }
149
+
150
+ const defaultSpawnHelper = (command: string, args: readonly string[]): { pid?: number | undefined; unref(): void } =>
151
+ spawn(command, [...args], { detached: true, stdio: "ignore", windowsHide: true });
152
+
153
+ export type HandoffRunOutcome =
154
+ | "restarted"
155
+ | "caller_still_running"
156
+ | "plan_unreadable"
157
+ | "plan_expired"
158
+ | "not_lock_owner"
159
+ | "restart_incomplete";
160
+
161
+ function readPlan(path: string): DesktopRestartHandoffPlan | null {
162
+ try {
163
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
164
+ if (typeof parsed !== "object" || parsed === null) return null;
165
+ const view = parsed as Record<string, unknown>;
166
+ if (view.schemaVersion !== 1) return null;
167
+ const callerPid = view.callerPid;
168
+ const createdAtMs = view.createdAtMs;
169
+ if (typeof callerPid !== "number" || !Number.isSafeInteger(callerPid) || callerPid <= 0) return null;
170
+ if (typeof createdAtMs !== "number" || !Number.isFinite(createdAtMs)) return null;
171
+ return { schemaVersion: 1, callerPid, createdAtMs };
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /** True when the path is inside the opencodex home AND named like a plan this CLI writes. */
178
+ export function isOwnPlanPath(planPath: string, io: HandoffIo = {}): boolean {
179
+ const home = io.homeDir ?? getConfigDir();
180
+ let resolvedPlan: string;
181
+ let resolvedHome: string;
182
+ try {
183
+ resolvedHome = resolve(home);
184
+ resolvedPlan = resolve(planPath);
185
+ } catch {
186
+ return false;
187
+ }
188
+ if (dirname(resolvedPlan) !== resolvedHome) return false;
189
+ return /^desktop-restart-handoff-\d+-[a-z0-9]+\.json$/.test(basename(resolvedPlan));
190
+ }
191
+
192
+ function defaultIsAlive(pid: number): boolean {
193
+ try {
194
+ process.kill(pid, 0);
195
+ return true;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+
201
+ function defaultSleep(ms: number): void {
202
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
203
+ }
204
+
205
+ /**
206
+ * Append one JSON line per run. Counts, never command lines or OS error text: the same
207
+ * projection the management restart response already applies, for the same reason.
208
+ */
209
+ function appendLog(io: HandoffIo, entry: Record<string, unknown>): void {
210
+ try {
211
+ const path = handoffLogPath(io);
212
+ mkdirSync(dirname(path), { recursive: true });
213
+ appendFileSync(path, JSON.stringify({ at: new Date((io.now ?? Date.now)()).toISOString(), ...entry }) + "\n");
214
+ } catch {
215
+ /* the restart matters more than the record of it */
216
+ }
217
+ }
218
+
219
+ export interface HandoffRunIo extends HandoffIo {
220
+ restart?: (allowHandoff: false) => { relaunch: "started" | "skipped"; reason?: string; stopped: number[]; surviving: number[] };
221
+ readLockOwner?: () => number | null;
222
+ }
223
+
224
+ export async function runDesktopRestartHandoff(
225
+ planPath: string,
226
+ io: HandoffRunIo = {},
227
+ ): Promise<HandoffRunOutcome> {
228
+ const now = io.now ?? Date.now;
229
+ const self = io.pid ?? process.pid;
230
+ // Only ever touch a file this CLI could have written. Unlinking whatever --plan points
231
+ // at turned a hidden helper command into an unlink oracle: a same-uid caller could pass
232
+ // a config path and have it deleted on the way to being told the plan was unreadable.
233
+ if (!isOwnPlanPath(planPath, io)) {
234
+ appendLog(io, { outcome: "plan_unreadable" });
235
+ releaseDesktopRestartLock(io.lock);
236
+ return "plan_unreadable";
237
+ }
238
+ const plan = readPlan(planPath);
239
+ // Unlink only AFTER the shape is confirmed, so a file that merely lives in the right
240
+ // directory under the right name is still not destroyed by a malformed read.
241
+ if (plan) {
242
+ try { unlinkSync(planPath); } catch { /* the plan is single-use either way */ }
243
+ }
244
+ if (!plan) {
245
+ appendLog(io, { outcome: "plan_unreadable" });
246
+ releaseDesktopRestartLock(io.lock);
247
+ return "plan_unreadable";
248
+ }
249
+ if (now() - plan.createdAtMs > PLAN_MAX_AGE_MS) {
250
+ // A plan left behind by a crash must not restart the app hours later.
251
+ appendLog(io, { outcome: "plan_expired" });
252
+ releaseDesktopRestartLock(io.lock);
253
+ return "plan_expired";
254
+ }
255
+
256
+ const isAlive = io.isAlive ?? defaultIsAlive;
257
+ const sleep = io.sleep ?? defaultSleep;
258
+ const deadline = now() + CALLER_EXIT_TIMEOUT_MS;
259
+ // Bounded by polls as well as by the clock. The clock alone is not enough: if sleep
260
+ // does not actually advance time - a frozen clock, a no-op sleep - this becomes a hot
261
+ // spin that never exits, inside a detached process nobody is watching.
262
+ const maxPolls = Math.ceil(CALLER_EXIT_TIMEOUT_MS / CALLER_POLL_MS) + 1;
263
+ for (let poll = 0; poll < maxPolls && now() < deadline && isAlive(plan.callerPid); poll++) {
264
+ sleep(CALLER_POLL_MS);
265
+ }
266
+ if (isAlive(plan.callerPid)) {
267
+ // A caller that outlives the window is not the short-lived `ocx sync` this was built
268
+ // for, and quitting the app out from under an unknown long-running process is not
269
+ // something to guess about.
270
+ appendLog(io, { outcome: "caller_still_running", callerPid: plan.callerPid });
271
+ releaseDesktopRestartLock(io.lock);
272
+ return "caller_still_running";
273
+ }
274
+
275
+ // The lock must name THIS process. It was made out to us by the caller; if it names
276
+ // anybody else, the transfer failed or somebody reclaimed it, and acting now would be
277
+ // the unsynchronised second ladder the lock exists to prevent.
278
+ const owner = (io.readLockOwner ?? (() => readDesktopRestartLockOwner(io.lock)))();
279
+ if (owner !== self) {
280
+ appendLog(io, { outcome: "not_lock_owner" });
281
+ return "not_lock_owner";
282
+ }
283
+
284
+ try {
285
+ const restart = io.restart
286
+ ? io.restart(false)
287
+ : (await import("../desktop-app-restart")).restartCodexDesktopApp({
288
+ allowHandoff: false,
289
+ lock: io.lock,
290
+ });
291
+ const ok = restart.relaunch === "started";
292
+ appendLog(io, {
293
+ outcome: ok ? "restarted" : "restart_incomplete",
294
+ reason: restart.reason,
295
+ stopped: restart.stopped.length,
296
+ surviving: restart.surviving.length,
297
+ });
298
+ return ok ? "restarted" : "restart_incomplete";
299
+ } finally {
300
+ releaseDesktopRestartLock(io.lock);
301
+ }
302
+ }
303
+
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Linux adapter for the Codex desktop-app restart.
3
+ *
4
+ * Measured shape (devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md):
5
+ *
6
+ * /usr/bin/chatgpt -> /usr/lib/chatgpt/codex-launcher (2-line sh script)
7
+ * that execs /usr/lib/chatgpt/ChatGPT
8
+ *
9
+ * 3284901 /usr/lib/chatgpt/ChatGPT (root: no --type=)
10
+ * 3284913 /usr/lib/chatgpt/ChatGPT --type=zygote
11
+ * 3284951 /usr/lib/chatgpt/ChatGPT --type=gpu-process
12
+ * 3284953 /usr/lib/chatgpt/ChatGPT --type=utility ...
13
+ *
14
+ * The root's /proc/<pid>/environ is 1902 bytes of NUL: Chromium scrubs it after
15
+ * startup. Session variables survive only in children that inherited them before
16
+ * the scrub. Measured on lidge: DISPLAY=:1, XDG_SESSION_TYPE=x11,
17
+ * XDG_RUNTIME_DIR=/run/user/1000,
18
+ * DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus.
19
+ */
20
+ import { spawn, execFileSync } from "node:child_process";
21
+ import {
22
+ existsSync,
23
+ readdirSync,
24
+ readFileSync,
25
+ readlinkSync,
26
+ realpathSync,
27
+ statSync,
28
+ } from "node:fs";
29
+ import { dirname, join } from "node:path";
30
+ import {
31
+ isUnderRoot,
32
+ type DesktopAppAdapter,
33
+ type DesktopAppInstall,
34
+ type DesktopExec,
35
+ type DesktopProcess,
36
+ } from "./types";
37
+
38
+ const INSTALL_ID = "chatgpt";
39
+ const SHELL_NAME = "ChatGPT";
40
+ /** Absolute candidates only. A chatgpt earlier on PATH must not redirect a kill or a launch. */
41
+ const LAUNCHER_CANDIDATES = ["/usr/bin/chatgpt", "/usr/local/bin/chatgpt"] as const;
42
+ const SETSID = "/usr/bin/setsid";
43
+ const PROC_ROOT = "/proc";
44
+ const MAX_ANCESTRY_HOPS = 16;
45
+ /** Group-write 0o020 | world-write 0o002. Sticky/setgid bits are not a trust failure. */
46
+ const GROUP_OR_WORLD_WRITE = 0o022;
47
+
48
+ /**
49
+ * Copied into the relaunch environment and nothing else.
50
+ *
51
+ * /proc/<pid>/environ is another process's full environment and routinely carries
52
+ * API keys and session tokens. Copying it wholesale would move credentials between
53
+ * security contexts for no benefit.
54
+ */
55
+ const SESSION_ENV_KEYS = [
56
+ "DISPLAY",
57
+ "WAYLAND_DISPLAY",
58
+ "XDG_RUNTIME_DIR",
59
+ "XDG_SESSION_TYPE",
60
+ "DBUS_SESSION_BUS_ADDRESS",
61
+ ] as const;
62
+
63
+ const MINIMAL_ENV_KEYS = ["HOME", "USER", "LOGNAME", "LANG"] as const;
64
+ const RELAUNCH_PATH = "/usr/local/bin:/usr/bin:/bin";
65
+
66
+ function procPath(pid: number, leaf: string): string {
67
+ return PROC_ROOT + "/" + String(pid) + "/" + leaf;
68
+ }
69
+
70
+ function readProcExe(pid: number): string {
71
+ const link = readlinkSync(procPath(pid, "exe"));
72
+ return typeof link === "string" ? link : Buffer.from(link).toString("utf8");
73
+ }
74
+
75
+ function currentUid(): number | undefined {
76
+ try {
77
+ return typeof process.getuid === "function" ? process.getuid() : undefined;
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Root and shell must be uid 0 and not group/world writable.
85
+ *
86
+ * dirname(realpath(launcher)) alone is not enough: /usr/local/bin is group-writable
87
+ * on some systems, so a planted chatgpt -> ~/x/codex-launcher beside a ~/x/ChatGPT
88
+ * would make an attacker-chosen directory the membership boundary and the relaunch
89
+ * target. A failed check is a discovery failure, never a fallback to the next
90
+ * candidate -- otherwise the planted /usr/local/bin/chatgpt becomes the target.
91
+ *
92
+ * stat (follow) rather than lstat: a root-owned symlink to a user-writable directory
93
+ * must fail on the target's mode, not pass on the symlink's.
94
+ */
95
+ function isTrustedSystemPath(path: string): boolean {
96
+ try {
97
+ const st = statSync(path);
98
+ return st.uid === 0 && (st.mode & GROUP_OR_WORLD_WRITE) === 0;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ function discoverFromCandidate(candidate: string): DesktopAppInstall | null | "absent" {
105
+ let resolvedLauncher: string;
106
+ try {
107
+ resolvedLauncher = realpathSync(candidate);
108
+ } catch {
109
+ return "absent";
110
+ }
111
+ const root = dirname(resolvedLauncher);
112
+ const shell = join(root, SHELL_NAME);
113
+ if (!isTrustedSystemPath(root) || !isTrustedSystemPath(shell)) return null;
114
+ return { id: INSTALL_ID, root, relaunch: candidate };
115
+ }
116
+
117
+ function parsePpidAndRealUid(status: string): { parentPid: number; uid: number } | null {
118
+ const ppidMatch = /^PPid:\s+(\d+)/m.exec(status);
119
+ const uidMatch = /^Uid:\s+(\d+)/m.exec(status);
120
+ if (!ppidMatch || !uidMatch) return null;
121
+ const parentPid = Number(ppidMatch[1]);
122
+ const uid = Number(uidMatch[1]);
123
+ if (!Number.isSafeInteger(parentPid) || !Number.isSafeInteger(uid)) return null;
124
+ return { parentPid, uid };
125
+ }
126
+
127
+ /**
128
+ * Field 22 (starttime) as an opaque token. Located after the LAST ')' because comm
129
+ * can contain spaces and parentheses -- this app's helpers are literally named
130
+ * "Codex (Service)".
131
+ */
132
+ function parseStarttimeToken(stat: string): string | null {
133
+ const close = stat.lastIndexOf(")");
134
+ if (close < 0) return null;
135
+ const fields = stat.slice(close + 2).split(/\s+/);
136
+ const starttime = fields[19];
137
+ return starttime ? starttime : null;
138
+ }
139
+
140
+ function cmdlineHasElectronType(pid: number): boolean | "unreadable" {
141
+ try {
142
+ const args = readFileSync(procPath(pid, "cmdline")).toString("utf8").split("\0");
143
+ return args.some(arg => arg.startsWith("--type="));
144
+ } catch {
145
+ return "unreadable";
146
+ }
147
+ }
148
+
149
+ function parseEnviron(buf: Buffer): Record<string, string> {
150
+ const out: Record<string, string> = {};
151
+ for (const entry of buf.toString("utf8").split("\0")) {
152
+ if (!entry) continue;
153
+ const eq = entry.indexOf("=");
154
+ if (eq <= 0) continue;
155
+ out[entry.slice(0, eq)] = entry.slice(eq + 1);
156
+ }
157
+ return out;
158
+ }
159
+
160
+ function sessionEnvFrom(source: Record<string, string>): Record<string, string> {
161
+ const out: Record<string, string> = {};
162
+ for (const key of SESSION_ENV_KEYS) {
163
+ const value = source[key];
164
+ if (typeof value === "string" && value.length > 0) out[key] = value;
165
+ }
166
+ return out;
167
+ }
168
+
169
+ function byStarttimeAscending(a: DesktopProcess, b: DesktopProcess): number {
170
+ // starttime is a jiffies integer stored in a string. A lexical sort misorders
171
+ // it ("100" < "99"), so the oldest child -- the one most likely to still hold
172
+ // the pre-scrub session -- would not be tried first.
173
+ return Number(a.createdAt) - Number(b.createdAt);
174
+ }
175
+
176
+ function minimalRelaunchEnv(): Record<string, string> {
177
+ const env: Record<string, string> = { PATH: RELAUNCH_PATH };
178
+ for (const key of MINIMAL_ENV_KEYS) {
179
+ const value = process.env[key];
180
+ if (value !== undefined) env[key] = value;
181
+ }
182
+ return env;
183
+ }
184
+
185
+ let killProcess: (pid: number, signal: NodeJS.Signals) => void = (pid, signal) => {
186
+ process.kill(pid, signal);
187
+ };
188
+
189
+ /** Test-only seam, so a kill can be observed without ending a developer's own Codex. */
190
+ export function setLinuxKillForTests(
191
+ next: ((pid: number, signal: NodeJS.Signals) => void) | null,
192
+ ): void {
193
+ killProcess = next ?? ((pid, signal) => { process.kill(pid, signal); });
194
+ }
195
+
196
+ type LinuxSpawn = (
197
+ command: string,
198
+ args: readonly string[],
199
+ options: { detached: boolean; stdio: "ignore"; env: NodeJS.ProcessEnv },
200
+ ) => { pid?: number | undefined; unref(): void };
201
+
202
+ const defaultSpawn: LinuxSpawn = (command, args, options) => {
203
+ const child = spawn(command, [...args], {
204
+ detached: options.detached,
205
+ stdio: options.stdio,
206
+ env: options.env,
207
+ shell: false,
208
+ });
209
+ // Headless ENOENT arrives as an async 'error'; without a listener it is an
210
+ // uncaught exception that kills the caller after relaunch has already returned.
211
+ child.on("error", () => {});
212
+ return child;
213
+ };
214
+
215
+ let spawnProcess: LinuxSpawn = defaultSpawn;
216
+
217
+ /** Test-only seam, so relaunch can be observed without starting ChatGPT. */
218
+ export function setLinuxSpawnForTests(next: LinuxSpawn | null): void {
219
+ spawnProcess = next ?? defaultSpawn;
220
+ }
221
+
222
+ export const linuxDesktopAppAdapter: DesktopAppAdapter = {
223
+ discover(_exec): DesktopAppInstall | null {
224
+ for (const candidate of LAUNCHER_CANDIDATES) {
225
+ const found = discoverFromCandidate(candidate);
226
+ if (found === "absent") continue;
227
+ return found;
228
+ }
229
+ return null;
230
+ },
231
+
232
+ listProcesses(_exec, install): DesktopProcess[] | null {
233
+ // A missing or unreadable /proc is an enumeration failure, not absence. Collapsing
234
+ // those told users the app was not running and skipped a restart they asked for.
235
+ if (!existsSync(PROC_ROOT)) return null;
236
+ let names: string[];
237
+ try {
238
+ names = readdirSync(PROC_ROOT);
239
+ } catch {
240
+ return null;
241
+ }
242
+ const uid = currentUid();
243
+ if (uid === undefined) return null;
244
+
245
+ const out: DesktopProcess[] = [];
246
+ for (const name of names) {
247
+ if (!/^\d+$/.test(name)) continue;
248
+ const pid = Number(name);
249
+ if (!Number.isSafeInteger(pid)) continue;
250
+ try {
251
+ const executable = readProcExe(pid);
252
+ if (!isUnderRoot(executable, install.root)) continue;
253
+ const identity = parsePpidAndRealUid(readFileSync(procPath(pid, "status"), "utf8"));
254
+ if (!identity || identity.uid !== uid) continue;
255
+ const createdAt = parseStarttimeToken(readFileSync(procPath(pid, "stat"), "utf8"));
256
+ if (!createdAt) continue;
257
+ out.push({
258
+ pid,
259
+ parentPid: identity.parentPid,
260
+ createdAt,
261
+ executable,
262
+ });
263
+ } catch {
264
+ // Per-pid EACCES/ENOENT (and a pid that vanished mid-scan) skip that pid.
265
+ // They are not an enumeration failure.
266
+ continue;
267
+ }
268
+ }
269
+ return out;
270
+ },
271
+
272
+ isShell(entry, install): boolean {
273
+ if (entry.executable !== join(install.root, SHELL_NAME)) return false;
274
+ // Unreadable cmdline cannot prove this is the shell, so it is not a root.
275
+ return cmdlineHasElectronType(entry.pid) === false;
276
+ },
277
+
278
+ ancestryPids(_exec): number[] {
279
+ const chain: number[] = [process.pid];
280
+ let current = process.pid;
281
+ for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) {
282
+ let status: string;
283
+ try {
284
+ status = readFileSync(procPath(current, "status"), "utf8");
285
+ } catch (error) {
286
+ // Two different situations arrive here and they must not be merged.
287
+ //
288
+ // ENOENT means the pid is simply gone. That is a CLEAN end of chain and the
289
+ // normal state above an orphaned handoff helper, so the chain collected so far
290
+ // is returned and the caller can still be judged outside the tree.
291
+ //
292
+ // Any OTHER error means we could not look, and "could not look" must never be
293
+ // read as "we are outside the tree": that reading lets the ladder signal the
294
+ // shell hosting the caller's own session. Hop 0 is this process itself, which
295
+ // always exists, so a failure there is always a read failure.
296
+ const code = (error as NodeJS.ErrnoException | null)?.code;
297
+ if (hop > 0 && code === "ENOENT") return chain;
298
+ return [];
299
+ }
300
+ const parsed = parsePpidAndRealUid(status);
301
+ if (!parsed || parsed.parentPid <= 0) return chain;
302
+ const parent = parsed.parentPid;
303
+ if (chain.includes(parent)) return chain;
304
+ chain.push(parent);
305
+ if (parent === 1) return chain;
306
+ current = parent;
307
+ }
308
+ // Bound reached without finding the top. A truncated chain silently defeats
309
+ // the self-ancestry intersection, so this reports "could not establish".
310
+ return [];
311
+ },
312
+
313
+ requestQuit(_exec, _install, root): void {
314
+ // Honest ceiling: /proc/<root>/status on lidge had SIGTERM in neither SigCgt
315
+ // nor SigIgn, so the Linux shell has the default SIGTERM disposition.
316
+ // SIGTERM here is termination, not a graceful shutdown request. The app
317
+ // registers no DBus quit method and has no systemd unit.
318
+ killProcess(root.pid, "SIGTERM");
319
+ },
320
+
321
+ forceStop(_exec, root): void {
322
+ killProcess(root.pid, "SIGKILL");
323
+ },
324
+
325
+ captureRelaunchContext(_exec, _install, processes): Record<string, string> {
326
+ const ordered = processes.slice().sort(byStarttimeAscending);
327
+ for (const entry of ordered) {
328
+ let buf: Buffer;
329
+ try {
330
+ buf = readFileSync(procPath(entry.pid, "environ"));
331
+ } catch {
332
+ continue;
333
+ }
334
+ const environ = parseEnviron(buf);
335
+ // The root is typically oldest and all-NUL after Chromium's scrub. Keep
336
+ // walking until a child that inherited the session before the scrub.
337
+ if (!environ.XDG_RUNTIME_DIR) continue;
338
+ return sessionEnvFrom(environ);
339
+ }
340
+ return {};
341
+ },
342
+
343
+ relaunch(_exec, install, context): void {
344
+ if (!context.XDG_RUNTIME_DIR) {
345
+ // An app started without a session cannot reach the compositor, but
346
+ // Electron still takes the single-instance lock, so the user's real
347
+ // session then cannot start either.
348
+ throw new Error(
349
+ "missing graphical session: XDG_RUNTIME_DIR was not recovered from the live process tree",
350
+ );
351
+ }
352
+ const env = { ...minimalRelaunchEnv(), ...sessionEnvFrom(context) };
353
+ // The launcher is a sh script and Electron resolves its user-data directory
354
+ // from HOME. Starting it with only the five session variables would produce
355
+ // an app that launches and then behaves as a different user profile.
356
+ const child = spawnProcess(SETSID, [install.relaunch], {
357
+ detached: true,
358
+ stdio: "ignore",
359
+ env,
360
+ });
361
+ // A detached child reports a failed launch asynchronously, and this process is
362
+ // about to stop caring about it, so the 'error' event has nobody to reach. An
363
+ // absent pid is the synchronous signal that the spawn never happened - without
364
+ // this check a missing /usr/bin/setsid still reported relaunch: "started".
365
+ if (child.pid === undefined) {
366
+ throw new Error("failed to spawn " + SETSID + " for the Codex desktop app relaunch");
367
+ }
368
+ // detached already calls setsid(2); the setsid binary then auto-forks because
369
+ // it finds itself a group leader. The overlap is deliberate belt-and-braces
370
+ // against a runtime that changes detached semantics. No --fork is needed.
371
+ child.unref();
372
+ },
373
+ };
374
+
375
+ /**
376
+ * Linux needs no subprocess for discovery or enumeration - everything comes from
377
+ * /proc and the filesystem - so this exists only to satisfy the shared contract and
378
+ * to keep the ladder's adapter selection uniform. It is deliberately execFileSync
379
+ * with a bounded timeout rather than a throwing stub, so a future adapter method that
380
+ * does need a subprocess gets the same trusted-path, bounded-probe treatment as the
381
+ * other two platforms instead of inventing its own.
382
+ */
383
+ export const linuxDefaultExec: DesktopExec = (file, args, options) => execFileSync(file, [...args], {
384
+ encoding: "utf-8",
385
+ stdio: ["ignore", "pipe", "ignore"],
386
+ timeout: options?.timeout ?? 10_000,
387
+ });
388
+