@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.
- package/gui/dist/assets/{index-D7ynYo2K.js → index-B4VYfZcY.js} +2 -2
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/devin.ts +39 -7
- package/src/cli/capabilities.ts +4 -2
- package/src/cli/catalog.ts +39 -10
- package/src/cli/dispatch.ts +19 -64
- package/src/cli/doctor.ts +1 -1
- package/src/cli/internal-command.ts +44 -0
- package/src/cli/registry.ts +9 -7
- package/src/cli/restart-scope.ts +184 -0
- package/src/codex/app-server-processes.ts +20 -5
- package/src/codex/app-server-restart-service.ts +29 -0
- package/src/codex/catalog/provider-fetch.ts +13 -5
- package/src/codex/catalog/sync.ts +12 -3
- package/src/codex/desktop-app/darwin.ts +268 -0
- package/src/codex/desktop-app/handoff.ts +303 -0
- package/src/codex/desktop-app/linux.ts +388 -0
- package/src/codex/desktop-app/lock.ts +226 -0
- package/src/codex/desktop-app/types.ts +141 -0
- package/src/codex/desktop-app/windows.ts +239 -0
- package/src/codex/desktop-app-restart.ts +264 -279
- package/src/codex/inject.ts +83 -21
- package/src/codex/sync.ts +16 -22
- package/src/generated/compatibility-version.json +58 -22
- package/src/lib/codex-restart-contract.ts +31 -0
- package/src/providers/registry.ts +13 -12
- package/src/server/responses/core.ts +62 -4
- package/src/server/responses/encrypted-payload.ts +161 -0
- package/src/server/responses.ts +1 -1
- package/src/types/provider.ts +6 -5
- package/src/web-search/index.ts +14 -67
- package/src/web-search/passthrough-bridge.ts +256 -32
- 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
|
+
|