@bitkyc08/opencodex 2.53.0 → 2.54.0
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-BlO4Yl6q.js → index-CkvITofZ.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,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Singleton lock for desktop-app restarts.
|
|
3
|
+
*
|
|
4
|
+
* Two restarts running at once are not merely wasteful, they are destructive: the
|
|
5
|
+
* first quits the app and relaunches it, the second re-enumerates during that window,
|
|
6
|
+
* sees the FRESHLY STARTED shell as a target, and kills it. Two
|
|
7
|
+
* `ocx sync --restart-codex` runs, or one handoff racing an ssh-issued direct run,
|
|
8
|
+
* are enough to produce it.
|
|
9
|
+
*
|
|
10
|
+
* Contended callers do not queue. Queueing would rebuild the same race one step
|
|
11
|
+
* later, so a caller that cannot take the lock reports `restart_in_flight` and stops.
|
|
12
|
+
*
|
|
13
|
+
* Two properties make the wp5 handoff work on top of this:
|
|
14
|
+
*
|
|
15
|
+
* - **Own-pid reentrancy.** A lock already naming this pid counts as held, not as
|
|
16
|
+
* contention. That is what lets the detached helper run the ordinary ladder — the
|
|
17
|
+
* caller hands it a lock already made out to it, and the helper takes no special
|
|
18
|
+
* path. Without this the feature deadlocks: the caller holds the lock, discovers
|
|
19
|
+
* it is inside the tree, and spawns a helper that waits for a lock its own parent
|
|
20
|
+
* is holding.
|
|
21
|
+
* - **Compare-and-delete release.** A process only ever deletes a lock naming
|
|
22
|
+
* itself. An unconditional unlink would let a late release destroy somebody else's
|
|
23
|
+
* live lock, which is exactly the mutual exclusion this file exists to provide.
|
|
24
|
+
*
|
|
25
|
+
* Design: `devlog/_plan/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md` §4.1.
|
|
26
|
+
*/
|
|
27
|
+
import { mkdirSync, openSync, closeSync, writeSync, readFileSync, unlinkSync, renameSync } from "node:fs";
|
|
28
|
+
import { join, dirname } from "node:path";
|
|
29
|
+
import { getConfigDir } from "../../config/paths";
|
|
30
|
+
|
|
31
|
+
/** A lock older than this is stale regardless of what its owner pid says. */
|
|
32
|
+
const LOCK_MAX_AGE_MS = 5 * 60_000;
|
|
33
|
+
|
|
34
|
+
export interface DesktopRestartLockRecord {
|
|
35
|
+
ownerPid: number;
|
|
36
|
+
createdAtMs: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DesktopRestartLockIo {
|
|
40
|
+
lockPath?: string;
|
|
41
|
+
isAlive?: (pid: number) => boolean;
|
|
42
|
+
now?: () => number;
|
|
43
|
+
pid?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function defaultIsAlive(pid: number): boolean {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, 0);
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readRecord(path: string): DesktopRestartLockRecord | null {
|
|
56
|
+
try {
|
|
57
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
58
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
59
|
+
const view = parsed as Record<string, unknown>;
|
|
60
|
+
const ownerPid = view.ownerPid;
|
|
61
|
+
const createdAtMs = view.createdAtMs;
|
|
62
|
+
if (typeof ownerPid !== "number" || !Number.isSafeInteger(ownerPid) || ownerPid <= 0) return null;
|
|
63
|
+
if (typeof createdAtMs !== "number" || !Number.isFinite(createdAtMs)) return null;
|
|
64
|
+
return { ownerPid, createdAtMs };
|
|
65
|
+
} catch {
|
|
66
|
+
// Unreadable or malformed: treat as absent so a corrupted file cannot wedge every
|
|
67
|
+
// future restart. The staleness rules below still bound how long a real lock holds.
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Exclusive create ON THE LOCK PATH. This is the whole mutual exclusion.
|
|
74
|
+
*
|
|
75
|
+
* The obvious-looking alternative - write a staging file with `wx` and `rename` it
|
|
76
|
+
* over the lock - is NOT exclusive: `wx` on a unique staging name always succeeds, and
|
|
77
|
+
* both racers then rename, so both believe they hold the lock and each kills the
|
|
78
|
+
* other's freshly relaunched app. The exclusivity has to come from `O_EXCL` on the
|
|
79
|
+
* contended path itself.
|
|
80
|
+
*/
|
|
81
|
+
function tryCreateExclusive(path: string, record: DesktopRestartLockRecord): boolean {
|
|
82
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
83
|
+
let fd: number;
|
|
84
|
+
try {
|
|
85
|
+
fd = openSync(path, "wx", 0o600);
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
writeSync(fd, JSON.stringify(record));
|
|
91
|
+
} finally {
|
|
92
|
+
closeSync(fd);
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Atomic replace, used ONLY by an owner handing the lock to its helper.
|
|
99
|
+
*
|
|
100
|
+
* Safe there precisely because it is not the contended path: the caller already holds
|
|
101
|
+
* the lock, so there is no race to lose.
|
|
102
|
+
*/
|
|
103
|
+
function writeRecord(path: string, record: DesktopRestartLockRecord): void {
|
|
104
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
105
|
+
const staging = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
106
|
+
const fd = openSync(staging, "wx", 0o600);
|
|
107
|
+
try {
|
|
108
|
+
writeSync(fd, JSON.stringify(record));
|
|
109
|
+
} finally {
|
|
110
|
+
closeSync(fd);
|
|
111
|
+
}
|
|
112
|
+
renameSync(staging, path);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type DesktopRestartLockAcquisition =
|
|
116
|
+
| { acquired: true; record: DesktopRestartLockRecord }
|
|
117
|
+
| { acquired: false; heldBy: number };
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Take the lock, or report who holds it.
|
|
121
|
+
*
|
|
122
|
+
* Staleness is decided by owner liveness first and age second. Both read liveness by
|
|
123
|
+
* pid, so both inherit a small exposure: a recycled pid inside the window reads as
|
|
124
|
+
* "still alive" and produces a false `restart_in_flight`. That fails in the safe
|
|
125
|
+
* direction — a restart that did not happen, rather than one that happened to the
|
|
126
|
+
* wrong process — and the age bound stops it lasting.
|
|
127
|
+
*/
|
|
128
|
+
export function acquireDesktopRestartLock(
|
|
129
|
+
io: DesktopRestartLockIo = {},
|
|
130
|
+
): DesktopRestartLockAcquisition {
|
|
131
|
+
const path = io.lockPath ?? defaultLockPath();
|
|
132
|
+
const isAlive = io.isAlive ?? defaultIsAlive;
|
|
133
|
+
const now = io.now ?? Date.now;
|
|
134
|
+
const self = io.pid ?? process.pid;
|
|
135
|
+
|
|
136
|
+
const existing = readRecord(path);
|
|
137
|
+
if (existing) {
|
|
138
|
+
// Own-pid reentrancy: a lock handed to us, or one we already hold.
|
|
139
|
+
if (existing.ownerPid === self) return { acquired: true, record: existing };
|
|
140
|
+
const stale = !isAlive(existing.ownerPid) || now() - existing.createdAtMs > LOCK_MAX_AGE_MS;
|
|
141
|
+
if (!stale) return { acquired: false, heldBy: existing.ownerPid };
|
|
142
|
+
// Stale. Clear it and then compete for the exclusive create like anyone else rather
|
|
143
|
+
// than writing straight over it: two processes can observe the same stale lock, and
|
|
144
|
+
// only O_EXCL decides which of them actually gets it.
|
|
145
|
+
try {
|
|
146
|
+
unlinkSync(path);
|
|
147
|
+
} catch {
|
|
148
|
+
/* somebody else cleared it first, which is fine - the create below still decides */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const record: DesktopRestartLockRecord = { ownerPid: self, createdAtMs: now() };
|
|
153
|
+
if (tryCreateExclusive(path, record)) return { acquired: true, record };
|
|
154
|
+
|
|
155
|
+
const winner = readRecord(path);
|
|
156
|
+
if (winner && winner.ownerPid === self) return { acquired: true, record: winner };
|
|
157
|
+
if (winner) {
|
|
158
|
+
// Lost the race fairly. Reporting contention is the honest answer; retrying would be
|
|
159
|
+
// the queue this deliberately avoids, and queueing rebuilds the same race one step
|
|
160
|
+
// later.
|
|
161
|
+
return { acquired: false, heldBy: winner.ownerPid };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The file exists but names nobody: truncated, corrupt, or left behind by a writer
|
|
165
|
+
// that died between creating it and writing it. Without this it would block every
|
|
166
|
+
// future restart forever, reported as contention with an owner of 0 - a lock nobody
|
|
167
|
+
// holds and nobody can clear. Remove it and make exactly one more attempt, so a real
|
|
168
|
+
// winner that appears in between still keeps the lock.
|
|
169
|
+
try {
|
|
170
|
+
unlinkSync(path);
|
|
171
|
+
} catch {
|
|
172
|
+
/* somebody else cleared it first */
|
|
173
|
+
}
|
|
174
|
+
if (tryCreateExclusive(path, record)) return { acquired: true, record };
|
|
175
|
+
const successor = readRecord(path);
|
|
176
|
+
if (successor && successor.ownerPid === self) return { acquired: true, record: successor };
|
|
177
|
+
return { acquired: false, heldBy: successor?.ownerPid ?? 0 };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Hand the lock to a process that does not exist yet as far as the lock is concerned.
|
|
182
|
+
*
|
|
183
|
+
* Called only after a successful helper spawn. Doing it before would strand the lock
|
|
184
|
+
* on a pid that never came into being, and the next restart would have to wait out
|
|
185
|
+
* the staleness window for no reason.
|
|
186
|
+
*/
|
|
187
|
+
export function transferDesktopRestartLock(
|
|
188
|
+
toPid: number,
|
|
189
|
+
io: DesktopRestartLockIo = {},
|
|
190
|
+
): boolean {
|
|
191
|
+
const path = io.lockPath ?? defaultLockPath();
|
|
192
|
+
const now = io.now ?? Date.now;
|
|
193
|
+
const self = io.pid ?? process.pid;
|
|
194
|
+
const existing = readRecord(path);
|
|
195
|
+
if (!existing || existing.ownerPid !== self) return false;
|
|
196
|
+
try {
|
|
197
|
+
writeRecord(path, { ownerPid: toPid, createdAtMs: now() });
|
|
198
|
+
return true;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Who currently holds the lock, or null when nobody does or it is unreadable. */
|
|
205
|
+
export function readDesktopRestartLockOwner(io: DesktopRestartLockIo = {}): number | null {
|
|
206
|
+
return readRecord(io.lockPath ?? defaultLockPath())?.ownerPid ?? null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Compare-and-delete. Never removes a lock owned by another process. */
|
|
210
|
+
export function releaseDesktopRestartLock(io: DesktopRestartLockIo = {}): void {
|
|
211
|
+
const path = io.lockPath ?? defaultLockPath();
|
|
212
|
+
const self = io.pid ?? process.pid;
|
|
213
|
+
const existing = readRecord(path);
|
|
214
|
+
if (!existing || existing.ownerPid !== self) return;
|
|
215
|
+
try {
|
|
216
|
+
unlinkSync(path);
|
|
217
|
+
} catch {
|
|
218
|
+
/* already gone */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function defaultLockPath(): string {
|
|
223
|
+
// getConfigDir owns OPENCODEX_HOME resolution, including ~ expansion and the caching
|
|
224
|
+
// every other consumer sees. Re-deriving it here would drift from it.
|
|
225
|
+
return join(getConfigDir(), "desktop-restart.lock");
|
|
226
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform contract for the Codex desktop-app restart.
|
|
3
|
+
*
|
|
4
|
+
* One ladder in `../desktop-app-restart.ts` drives every platform; the only things
|
|
5
|
+
* that actually differ are identity, discovery, membership, the two stop primitives
|
|
6
|
+
* and relaunch. Keeping those seven behind this interface is what stopped the
|
|
7
|
+
* PID-reuse and fail-closed reasoning from being re-derived three times, once per
|
|
8
|
+
* operating system, with two of the three getting it subtly wrong.
|
|
9
|
+
*
|
|
10
|
+
* Design and audit history: `devlog/_plan/260913_cross_platform_desktop_app_restart/`.
|
|
11
|
+
*/
|
|
12
|
+
import { sep } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** Bounded subprocess options. A hung probe must never wedge `ocx sync`. */
|
|
15
|
+
export interface DesktopAppExecOptions {
|
|
16
|
+
timeout?: number;
|
|
17
|
+
windowsHide?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Returns stdout. Options are part of the seam so the timeout is testable. */
|
|
21
|
+
export type DesktopExec = (
|
|
22
|
+
file: string,
|
|
23
|
+
args: readonly string[],
|
|
24
|
+
options?: DesktopAppExecOptions,
|
|
25
|
+
) => string;
|
|
26
|
+
|
|
27
|
+
/** One discovered installation of the Codex desktop app. */
|
|
28
|
+
export interface DesktopAppInstall {
|
|
29
|
+
/** Stable platform-specific identity, used in messages and for relaunch. */
|
|
30
|
+
id: string;
|
|
31
|
+
/**
|
|
32
|
+
* Absolute, ALREADY `realpath`-RESOLVED directory every member executable must
|
|
33
|
+
* live under. Resolution happens once here rather than per candidate so that
|
|
34
|
+
* {@link isUnderRoot} is a pure string comparison against a trusted value.
|
|
35
|
+
*/
|
|
36
|
+
root: string;
|
|
37
|
+
/** Opaque relaunch descriptor only the owning adapter interprets. */
|
|
38
|
+
relaunch: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DesktopProcess {
|
|
42
|
+
pid: number;
|
|
43
|
+
parentPid: number;
|
|
44
|
+
/**
|
|
45
|
+
* Platform-native start-time token, compared verbatim and never parsed.
|
|
46
|
+
*
|
|
47
|
+
* This is the field that distinguishes a process from a replacement that reused
|
|
48
|
+
* its pid. A pid alone is not an identity across a graceful-close window long
|
|
49
|
+
* enough for the OS to recycle one.
|
|
50
|
+
*/
|
|
51
|
+
createdAt: string;
|
|
52
|
+
/** Absolute executable path, used for membership and the shell predicate. */
|
|
53
|
+
executable: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DesktopAppAdapter {
|
|
57
|
+
/** `null` means discovery failed. Never throws. */
|
|
58
|
+
discover(exec: DesktopExec): DesktopAppInstall | null;
|
|
59
|
+
/**
|
|
60
|
+
* `null` means the probe could not RUN. `[]` means it ran and found nothing.
|
|
61
|
+
*
|
|
62
|
+
* The distinction is not pedantic: collapsing them told users the app was not
|
|
63
|
+
* running and silently skipped a restart they had explicitly asked for (#2557).
|
|
64
|
+
*/
|
|
65
|
+
listProcesses(exec: DesktopExec, install: DesktopAppInstall): DesktopProcess[] | null;
|
|
66
|
+
/**
|
|
67
|
+
* True when this member is the app shell rather than a helper.
|
|
68
|
+
*
|
|
69
|
+
* "Parent is not a member" is not sufficient on its own. macOS crashpad handlers
|
|
70
|
+
* are launchd children, so they sit at ppid 1 and would otherwise be classified
|
|
71
|
+
* as roots — including stale ones left by an instance that already exited, which
|
|
72
|
+
* would be signalled and could never be made to "survive" cleanly.
|
|
73
|
+
*/
|
|
74
|
+
isShell(process: DesktopProcess, install: DesktopAppInstall): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Ancestry of the current process, innermost first.
|
|
77
|
+
*
|
|
78
|
+
* `[]` means "could not establish that we are outside the tree" and the ladder
|
|
79
|
+
* fails closed on it. A parent pid naming no live process is NOT that case: it is
|
|
80
|
+
* a clean end of chain, which is the normal state of the detached handoff helper
|
|
81
|
+
* on Windows, where orphans are never reparented.
|
|
82
|
+
*/
|
|
83
|
+
ancestryPids(exec: DesktopExec): number[];
|
|
84
|
+
/** Ask the app to quit. Best effort; the ladder decides what happens next. */
|
|
85
|
+
requestQuit(exec: DesktopExec, install: DesktopAppInstall, root: DesktopProcess): void;
|
|
86
|
+
/** Unconditional termination of one shell and its tree. */
|
|
87
|
+
forceStop(exec: DesktopExec, root: DesktopProcess): void;
|
|
88
|
+
/**
|
|
89
|
+
* Capture what the relaunch will need, from the LIVE tree, before anything stops.
|
|
90
|
+
*
|
|
91
|
+
* This is on the contract rather than inside the Linux adapter because of its
|
|
92
|
+
* ordering obligation. A ladder that called it after termination would work on
|
|
93
|
+
* macOS and Windows and produce a Linux app that cannot reach the compositor —
|
|
94
|
+
* the failure would look platform-specific when it is really an ordering bug.
|
|
95
|
+
*/
|
|
96
|
+
captureRelaunchContext(
|
|
97
|
+
exec: DesktopExec,
|
|
98
|
+
install: DesktopAppInstall,
|
|
99
|
+
processes: readonly DesktopProcess[],
|
|
100
|
+
): Record<string, string>;
|
|
101
|
+
/** Start the app again. THROWS on failure; the ladder reports `relaunch_failed`. */
|
|
102
|
+
relaunch(
|
|
103
|
+
exec: DesktopExec,
|
|
104
|
+
install: DesktopAppInstall,
|
|
105
|
+
context: Record<string, string>,
|
|
106
|
+
): void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Path-boundary membership test.
|
|
111
|
+
*
|
|
112
|
+
* A raw `startsWith` admits siblings: an install root of `/usr/lib/chatgpt` would
|
|
113
|
+
* also match `/usr/lib/chatgpt-evil/ChatGPT`, and `/Applications/ChatGPT.app` would
|
|
114
|
+
* match `/Applications/ChatGPT.app-evil/...`. Both are plantable by the same user
|
|
115
|
+
* whose processes are about to be signalled, so same-uid scoping does not cover it.
|
|
116
|
+
*
|
|
117
|
+
* `root` is expected to be `realpath`-resolved by discovery already.
|
|
118
|
+
*/
|
|
119
|
+
export function isUnderRoot(executable: string, root: string): boolean {
|
|
120
|
+
if (!executable || !root) return false;
|
|
121
|
+
if (executable === root) return true;
|
|
122
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
123
|
+
return executable.startsWith(prefix);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Shells whose parent is not itself a member of the tree.
|
|
128
|
+
*
|
|
129
|
+
* Helpers are deliberately enumerated but never returned here: they are what
|
|
130
|
+
* {@link DesktopAppAdapter.captureRelaunchContext} reads on Linux, and terminating
|
|
131
|
+
* the shell takes them anyway.
|
|
132
|
+
*/
|
|
133
|
+
export function rootShells(
|
|
134
|
+
processes: readonly DesktopProcess[],
|
|
135
|
+
install: DesktopAppInstall,
|
|
136
|
+
adapter: Pick<DesktopAppAdapter, "isShell">,
|
|
137
|
+
): DesktopProcess[] {
|
|
138
|
+
const memberPids = new Set(processes.map(entry => entry.pid));
|
|
139
|
+
return processes.filter(entry =>
|
|
140
|
+
adapter.isShell(entry, install) && !memberPids.has(entry.parentPid));
|
|
141
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows adapter for the Codex desktop-app restart.
|
|
3
|
+
*
|
|
4
|
+
* Moved from `desktop-app-restart.ts`. Behaviour is the Appx/CIM/taskkill path
|
|
5
|
+
* that already shipped: runtime package discovery, current-user GetOwner
|
|
6
|
+
* scoping, CloseMainWindow then taskkill /T /F, relaunch through the discovered
|
|
7
|
+
* AUMID. The shape is DesktopAppAdapter so the ladder, not this file, owns
|
|
8
|
+
* PID-reuse re-verification and the fail-closed sequencing.
|
|
9
|
+
*
|
|
10
|
+
* Measured (devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md §3):
|
|
11
|
+
* OpenAI.Codex MSIX, ChatGPT.exe, InstallLocation under WindowsApps.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { sep, win32 } from "node:path";
|
|
15
|
+
import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsTaskkillExe } from "../../lib/windows-elevation";
|
|
16
|
+
import {
|
|
17
|
+
isUnderRoot,
|
|
18
|
+
type DesktopAppAdapter,
|
|
19
|
+
type DesktopAppInstall,
|
|
20
|
+
type DesktopExec,
|
|
21
|
+
type DesktopProcess,
|
|
22
|
+
} from "./types";
|
|
23
|
+
|
|
24
|
+
/** Every probe is bounded; PowerShell module loading is the slow part. */
|
|
25
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
26
|
+
const MAX_ANCESTRY_HOPS = 16;
|
|
27
|
+
const SHELL_BASENAME = "chatgpt.exe";
|
|
28
|
+
|
|
29
|
+
const POWERSHELL_PROBE_OPTIONS = { timeout: PROBE_TIMEOUT_MS, windowsHide: true } as const;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* isUnderRoot prefixes with the host path.sep and is case-sensitive. Windows
|
|
33
|
+
* membership is case-insensitive, and this file is executed by Unix CI against
|
|
34
|
+
* backslash paths, so both sides are folded onto the host separator first.
|
|
35
|
+
* The boundary itself — sibling `OpenAI.Codex-evil` must not match root
|
|
36
|
+
* `OpenAI.Codex` — is still isUnderRoot's, which is why the PowerShell
|
|
37
|
+
* StartsWith is only a cheap pre-filter.
|
|
38
|
+
*/
|
|
39
|
+
function toHostMembershipPath(windowsPath: string): string {
|
|
40
|
+
const lowered = windowsPath.toLowerCase();
|
|
41
|
+
return sep === "\\" ? lowered : lowered.replaceAll("\\", "/");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isMemberExecutable(executable: string, root: string): boolean {
|
|
45
|
+
return isUnderRoot(toHostMembershipPath(executable), toHostMembershipPath(root));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Runtime discovery, never a hardcoded identifier. The beta MSIX package family
|
|
50
|
+
* changes between builds, so a literal AUMID would silently stop matching and
|
|
51
|
+
* then either do nothing or — worse — match a package we did not mean.
|
|
52
|
+
*/
|
|
53
|
+
function discoverPackage(exec: DesktopExec): DesktopAppInstall | null {
|
|
54
|
+
const script = [
|
|
55
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
56
|
+
"Import-Module Appx -ErrorAction SilentlyContinue",
|
|
57
|
+
"$p = Get-AppxPackage -Name OpenAI.Codex",
|
|
58
|
+
"if (-not $p) { $p = Get-AppxPackage -Name OpenAI.CodexBeta }",
|
|
59
|
+
"if (-not $p -or -not $p.InstallLocation) { 'MISS' } else {",
|
|
60
|
+
" $p.PackageFamilyName; $p.InstallLocation; \"$($p.PackageFamilyName)!App\"",
|
|
61
|
+
"}",
|
|
62
|
+
].join("; ");
|
|
63
|
+
let stdout: string;
|
|
64
|
+
try {
|
|
65
|
+
stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], POWERSHELL_PROBE_OPTIONS);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const lines = stdout.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0);
|
|
70
|
+
if (lines.length < 3 || lines[0] === "MISS") return null;
|
|
71
|
+
const [family, installLocation, aumid] = lines;
|
|
72
|
+
if (!family || !installLocation || !aumid) return null;
|
|
73
|
+
return { id: family, root: installLocation, relaunch: aumid };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Only `ChatGPT.exe` processes whose image lives under the discovered install
|
|
78
|
+
* location AND owned by the current user. The install location alone is not
|
|
79
|
+
* enough: an MSIX package under `WindowsApps` is shared, so on a multi-user
|
|
80
|
+
* machine another account's Codex desktop matches the same path. The app-server
|
|
81
|
+
* collector already pays for `GetOwner` for exactly this reason.
|
|
82
|
+
*
|
|
83
|
+
* `CreationDate` is captured so a PID can be re-verified before it is signalled;
|
|
84
|
+
* a graceful-close window is long enough for Windows to recycle a PID.
|
|
85
|
+
*
|
|
86
|
+
* ExecutablePath is included so membership can be decided by {@link isUnderRoot}
|
|
87
|
+
* rather than by PowerShell's `StartsWith`, which is a prefix test and would
|
|
88
|
+
* admit a sibling `OpenAI.Codex-evil` directory.
|
|
89
|
+
*/
|
|
90
|
+
function listPackageProcesses(exec: DesktopExec, install: DesktopAppInstall): DesktopProcess[] | null {
|
|
91
|
+
const literal = install.root.replace(/'/g, "''");
|
|
92
|
+
const script = [
|
|
93
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
94
|
+
`$root = '${literal}'`,
|
|
95
|
+
"$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name",
|
|
96
|
+
"Get-CimInstance Win32_Process -Filter \"Name='ChatGPT.exe'\" |",
|
|
97
|
+
" Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, 'OrdinalIgnoreCase') } |",
|
|
98
|
+
" ForEach-Object {",
|
|
99
|
+
" $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner",
|
|
100
|
+
" if ($o -and $o.ReturnValue -eq 0 -and $o.User) {",
|
|
101
|
+
" $owner = if ($o.Domain) { \"$($o.Domain)\\$($o.User)\" } else { $o.User }",
|
|
102
|
+
" if ($owner -ieq $me) {",
|
|
103
|
+
" \"$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToString('o')) $($_.ExecutablePath)\"",
|
|
104
|
+
" }",
|
|
105
|
+
" }",
|
|
106
|
+
" }",
|
|
107
|
+
// Statements must be newline-separated. Joining with a space concatenates
|
|
108
|
+
// `$ErrorActionPreference='SilentlyContinue' $root = '...'` into one malformed statement,
|
|
109
|
+
// which PowerShell rejects — so the probe threw and every caller read "not running" (#2557).
|
|
110
|
+
].join("\n");
|
|
111
|
+
let stdout: string;
|
|
112
|
+
try {
|
|
113
|
+
stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], POWERSHELL_PROBE_OPTIONS);
|
|
114
|
+
} catch {
|
|
115
|
+
// A probe that could not run is NOT proof the app is absent. Returning [] here made a
|
|
116
|
+
// failed enumeration indistinguishable from "no targets", so the CLI reported the app as
|
|
117
|
+
// not running and skipped a restart the user had explicitly asked for.
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const processes: DesktopProcess[] = [];
|
|
121
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
122
|
+
const parsed = parseProcessLine(line, install.root);
|
|
123
|
+
if (parsed) processes.push(parsed);
|
|
124
|
+
}
|
|
125
|
+
return processes;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseProcessLine(line: string, root: string): DesktopProcess | null {
|
|
129
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)(?:\s+(.+))?$/.exec(line);
|
|
130
|
+
if (!match) return null;
|
|
131
|
+
const pid = Number(match[1]);
|
|
132
|
+
const parentPid = Number(match[2]);
|
|
133
|
+
const createdAt = match[3] ?? "";
|
|
134
|
+
const listed = (match[4] ?? "").trim();
|
|
135
|
+
// The live probe emits ExecutablePath. Historical listings, and the tests that
|
|
136
|
+
// script them, were three tokens because the PowerShell filter is already
|
|
137
|
+
// Name='ChatGPT.exe' under root. Synthesize that image so executable is
|
|
138
|
+
// populated without treating a missing path as a different process.
|
|
139
|
+
const executable = listed.length > 0 ? listed : `${root.replace(/[\\/]+$/, "")}\\ChatGPT.exe`;
|
|
140
|
+
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(parentPid) || !createdAt || !executable) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
// Authoritative membership. PowerShell StartsWith already cheap-filtered, but
|
|
144
|
+
// that test is a string prefix and is how a sibling install would sneak in.
|
|
145
|
+
if (!isMemberExecutable(executable, root)) return null;
|
|
146
|
+
return { pid, parentPid, createdAt, executable };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Full Windows parent chain for this process, innermost first.
|
|
151
|
+
*
|
|
152
|
+
* `process.ppid` is one level, which is not enough: a terminal hosted inside the
|
|
153
|
+
* desktop app sits several hops below `ChatGPT.exe`, so a one-level check would
|
|
154
|
+
* miss the exact case the guard exists for and we would terminate our own host.
|
|
155
|
+
* The chain therefore comes from CIM, with a bound so a corrupted parent cycle
|
|
156
|
+
* cannot spin.
|
|
157
|
+
*/
|
|
158
|
+
function windowsAncestryPids(exec: DesktopExec): number[] {
|
|
159
|
+
const chain: number[] = [process.pid];
|
|
160
|
+
let current = process.pid;
|
|
161
|
+
for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) {
|
|
162
|
+
let stdout: string;
|
|
163
|
+
try {
|
|
164
|
+
stdout = exec(resolveTrustedWindowsPowerShellExe(), [
|
|
165
|
+
"-NoProfile", "-NonInteractive", "-Command",
|
|
166
|
+
`$ErrorActionPreference='SilentlyContinue'; (Get-CimInstance Win32_Process -Filter "ProcessId=${current}").ParentProcessId`,
|
|
167
|
+
], POWERSHELL_PROBE_OPTIONS);
|
|
168
|
+
} catch {
|
|
169
|
+
// An unreadable chain must not be read as "not our ancestor".
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
const trimmed = stdout.trim();
|
|
173
|
+
// Empty output means the pid has no live CIM entry: a CLEAN end of chain, not
|
|
174
|
+
// a read failure. Windows never reparents orphans, so the detached handoff
|
|
175
|
+
// helper always has a dead parent link once its caller exits. Reading that as
|
|
176
|
+
// unreadable would make the helper refuse forever and the feature would never
|
|
177
|
+
// work on Windows.
|
|
178
|
+
if (trimmed === "") return chain;
|
|
179
|
+
const parent = Number(trimmed);
|
|
180
|
+
if (!Number.isSafeInteger(parent) || parent <= 0) return chain;
|
|
181
|
+
if (chain.includes(parent)) return chain;
|
|
182
|
+
chain.push(parent);
|
|
183
|
+
current = parent;
|
|
184
|
+
}
|
|
185
|
+
// Bound reached without finding the top. A truncated chain silently defeats the
|
|
186
|
+
// self-ancestry intersection, so this reports "could not establish" instead.
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const windowsDesktopAppAdapter: DesktopAppAdapter = {
|
|
191
|
+
discover(exec): DesktopAppInstall | null {
|
|
192
|
+
return discoverPackage(exec);
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
listProcesses(exec, install): DesktopProcess[] | null {
|
|
196
|
+
return listPackageProcesses(exec, install);
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
isShell(entry): boolean {
|
|
200
|
+
return win32.basename(entry.executable).toLowerCase() === SHELL_BASENAME;
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
ancestryPids(exec): number[] {
|
|
204
|
+
return windowsAncestryPids(exec);
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
requestQuit(exec, _install, root): void {
|
|
208
|
+
exec(resolveTrustedWindowsPowerShellExe(), [
|
|
209
|
+
"-NoProfile", "-NonInteractive", "-Command",
|
|
210
|
+
`$p = Get-Process -Id ${root.pid} -ErrorAction SilentlyContinue; if ($p) { [void]$p.CloseMainWindow() }`,
|
|
211
|
+
], POWERSHELL_PROBE_OPTIONS);
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
forceStop(exec, root): void {
|
|
215
|
+
exec(resolveTrustedWindowsTaskkillExe(), ["/PID", String(root.pid), "/T", "/F"], POWERSHELL_PROBE_OPTIONS);
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
captureRelaunchContext(): Record<string, string> {
|
|
219
|
+
// The session is supplied by the shell:AppsFolder launch, so nothing needs
|
|
220
|
+
// carrying forward.
|
|
221
|
+
return {};
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
relaunch(exec, install): void {
|
|
225
|
+
// Throws on failure so the ladder reports relaunch_failed. The old code
|
|
226
|
+
// returned targets_survived here, which was dishonest: everything HAD died
|
|
227
|
+
// and it was the relaunch that failed.
|
|
228
|
+
exec(resolveTrustedWindowsPowerShellExe(), [
|
|
229
|
+
"-NoProfile", "-NonInteractive", "-Command",
|
|
230
|
+
`Start-Process 'shell:AppsFolder\\${install.relaunch}'`,
|
|
231
|
+
], POWERSHELL_PROBE_OPTIONS);
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const windowsDefaultExec: DesktopExec = (file, args, options) => execFileSync(file, [...args], {
|
|
236
|
+
encoding: "utf-8",
|
|
237
|
+
timeout: options?.timeout ?? PROBE_TIMEOUT_MS,
|
|
238
|
+
windowsHide: options?.windowsHide ?? true,
|
|
239
|
+
});
|