@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.
Files changed (34) hide show
  1. package/gui/dist/assets/{index-BlO4Yl6q.js → index-CkvITofZ.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,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
+ });