@bitkyc08/opencodex 2.10.2 → 2.11.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 (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
@@ -0,0 +1,301 @@
1
+ /**
2
+ * `ocx ready` — pure parser + dependency-injected runner.
3
+ *
4
+ * Lives outside cli/index.ts (which dispatches argv at module top level) so the
5
+ * full behavior is unit-testable without spawning a subprocess, opening a
6
+ * loopback socket, or touching the real HOME/CODEX_HOME. The runner returns an
7
+ * exit code; cli/index.ts only calls it and exits with the returned code.
8
+ *
9
+ * Contract (per P1 review):
10
+ * - Single wait loop. `--wait` waits for proxy discovery AND readiness within
11
+ * one bounded deadline (default 45s, max 300s). Without `--wait` a single
12
+ * identity-checked probe runs.
13
+ * - Invalid arguments return exit code 64 BEFORE any discovery/network work.
14
+ * - Output is sanitized: only the fixed `ready|pending|failed|unreachable`
15
+ * vocabulary, plus pid/port; never carries sync message, warning, path,
16
+ * provider, account, or error data.
17
+ */
18
+ import { DEFAULT_PROBE_TIMEOUT_MS, findLiveProxy, probeReadiness } from "../server/proxy-liveness";
19
+
20
+ /** Default --wait deadline (45s). */
21
+ export const DEFAULT_READY_WAIT_TIMEOUT_SECONDS = 45;
22
+ /** Maximum allowed --wait deadline (300s). */
23
+ export const MAX_READY_WAIT_TIMEOUT_SECONDS = 300;
24
+ const POLL_INTERVAL_MS = 500;
25
+ /**
26
+ * Cap a remaining deadline budget to a positive per-call IO timeout. Stays
27
+ * positive (>= 1ms) and never exceeds the logical remaining time nor the
28
+ * shared per-call ceiling (DEFAULT_PROBE_TIMEOUT_MS). Passed by the production
29
+ * defaults to findLiveProxy's / probeReadiness's timeoutMs so their fetch waits
30
+ * are bounded by the deadline.
31
+ */
32
+ function capIoTimeout(remainingMs: number): number {
33
+ return Math.max(1, Math.min(remainingMs, DEFAULT_PROBE_TIMEOUT_MS));
34
+ }
35
+
36
+ /** Fixed sanitized CLI status vocabulary. */
37
+ export type CliReadinessStatus = "ready" | "pending" | "failed" | "unreachable";
38
+
39
+ export interface ReadyArgs {
40
+ json: boolean;
41
+ wait: boolean;
42
+ timeoutSeconds: number;
43
+ }
44
+
45
+ export type ReadyParseResult =
46
+ | { ok: true; args: ReadyArgs }
47
+ | { ok: false; code: 64 };
48
+
49
+ /**
50
+ * Pure argument parser. Accepts `--json`, `--wait`, and
51
+ * `--wait --timeout <seconds>` (positive integer 1..300). Any unknown flag,
52
+ * positional argument, missing/invalid --timeout value, or `--timeout` without
53
+ * `--wait` is a usage error that must surface exit code 64.
54
+ */
55
+ export function parseReadyArgs(argv: string[]): ReadyParseResult {
56
+ let json = false;
57
+ let wait = false;
58
+ let timeoutSeconds: number | undefined;
59
+ for (let i = 0; i < argv.length; i++) {
60
+ const flag = argv[i];
61
+ if (flag === "--json") {
62
+ json = true;
63
+ continue;
64
+ }
65
+ if (flag === "--wait") {
66
+ wait = true;
67
+ continue;
68
+ }
69
+ if (flag === "--timeout") {
70
+ const raw = argv[i + 1];
71
+ // Positive finite integer seconds only; /^[0-9]+$/ rejects negatives, decimals, hex, and "".
72
+ if (raw === undefined || !/^[0-9]+$/.test(raw)) return { ok: false, code: 64 };
73
+ const n = Number(raw);
74
+ if (!Number.isInteger(n) || n < 1 || n > MAX_READY_WAIT_TIMEOUT_SECONDS) return { ok: false, code: 64 };
75
+ timeoutSeconds = n;
76
+ i++;
77
+ continue;
78
+ }
79
+ // Any unknown flag or positional argument is a usage error.
80
+ return { ok: false, code: 64 };
81
+ }
82
+ // --timeout only applies to --wait; pairing it with a single probe is a usage error.
83
+ if (timeoutSeconds !== undefined && !wait) return { ok: false, code: 64 };
84
+ return {
85
+ ok: true,
86
+ args: {
87
+ json,
88
+ wait,
89
+ timeoutSeconds: timeoutSeconds ?? DEFAULT_READY_WAIT_TIMEOUT_SECONDS,
90
+ },
91
+ };
92
+ }
93
+
94
+ export interface ReadyLive {
95
+ pid: number | null;
96
+ port: number;
97
+ hostname?: string;
98
+ }
99
+
100
+ export interface ReadyProbe {
101
+ ready: boolean;
102
+ status: "ready" | "pending" | "failed" | null;
103
+ pid: number | null;
104
+ port: number | null;
105
+ }
106
+
107
+ export interface ReadyIo {
108
+ /**
109
+ * Injected proxy discovery (defaults to the identity-checked findLiveProxy).
110
+ * Receives the positive remaining deadline budget (ms) in the --wait path so
111
+ * the production default can bound its fetch by the single deadline; receives
112
+ * `undefined` in the non-wait path so the default keeps its built-in timeout.
113
+ */
114
+ findLive?: (remainingMs: number | undefined) => Promise<ReadyLive | null>;
115
+ /**
116
+ * Injected readiness probe (defaults to the strict probeReadiness). The third
117
+ * argument is the positive remaining deadline budget (ms) in the --wait path,
118
+ * and `undefined` in the non-wait path (default probe timeout preserved).
119
+ */
120
+ probe?: (
121
+ port: number,
122
+ opts: { hostname?: string; expectedPid?: number },
123
+ remainingMs: number | undefined,
124
+ ) => Promise<ReadyProbe | null>;
125
+ /** Injected sleep so tests can poll without real timers. */
126
+ sleep?: (ms: number) => Promise<void>;
127
+ /** Injected clock so the deadline is deterministic without real time. */
128
+ now?: () => number;
129
+ /** Injected stdout (only `.log` is used). */
130
+ stdout?: { log: (s: string) => void };
131
+ }
132
+
133
+ function sanitizeProbeStatus(status: ReadyProbe["status"]): Exclude<CliReadinessStatus, "ready"> {
134
+ if (status === "failed") return "failed";
135
+ if (status === "pending") return "pending";
136
+ return "unreachable";
137
+ }
138
+
139
+ function report(
140
+ args: ReadyArgs,
141
+ ready: boolean,
142
+ status: CliReadinessStatus,
143
+ pid: number | null,
144
+ port: number | null,
145
+ stdout: { log: (s: string) => void },
146
+ ): void {
147
+ if (args.json) {
148
+ stdout.log(JSON.stringify({ ready, status, pid, port }));
149
+ return;
150
+ }
151
+ switch (status) {
152
+ case "ready":
153
+ stdout.log(`Proxy ready (PID ${pid ?? "?"}, port ${port ?? "?"})`);
154
+ return;
155
+ case "pending":
156
+ stdout.log("Proxy running but not ready yet (pending).");
157
+ return;
158
+ case "failed":
159
+ stdout.log("Proxy running but not ready (sync failed).");
160
+ return;
161
+ case "unreachable":
162
+ stdout.log("Proxy not reachable or readiness unavailable.");
163
+ return;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Run `ocx ready` over injected I/O. Returns the exit code (0 only when ready,
169
+ * 1 for not-ready/timeout, 64 for usage errors — though usage errors are
170
+ * normally caught by parseReadyArgs before this runs). The runner performs NO
171
+ * real subprocess/network work when the io injections are supplied.
172
+ */
173
+ export async function runReady(args: ReadyArgs, io: ReadyIo = {}): Promise<number> {
174
+ const find = io.findLive ?? (async (remainingMs: number | undefined) => {
175
+ // In the --wait path, forward an ABSOLUTE deadline derived from the real
176
+ // wall clock (Date.now, NOT the injected logical now) plus the remaining
177
+ // budget: the per-probe AbortSignal timeout is real wall-clock time, so the
178
+ // injected test clock must not govern the network deadline. findLiveProxy's
179
+ // deadlineAt budget recomputes remaining before each candidate probe and
180
+ // bounds each fetch by it (plus the per-probe cap below). Non-wait path
181
+ // keeps the built-in default.
182
+ // verifyPidFn is disabled: readiness is non-destructive, so the killable-pid
183
+ // OS command-line verification (WMIC/PowerShell, seconds on Windows) would
184
+ // run OUTSIDE the deadline budget for no readiness value. The /healthz
185
+ // identity marker and the strict /readyz contract validation remain.
186
+ const live = await findLiveProxy(
187
+ remainingMs === undefined
188
+ ? { verifyPidFn: () => null }
189
+ : { deadlineAt: Date.now() + remainingMs, timeoutMs: DEFAULT_PROBE_TIMEOUT_MS, verifyPidFn: () => null },
190
+ );
191
+ return live ? { pid: live.pid, port: live.port, hostname: live.hostname } : null;
192
+ });
193
+ const probe = io.probe ?? (async (port, opts, remainingMs) =>
194
+ probeReadiness(port, opts, remainingMs === undefined ? {} : { timeoutMs: capIoTimeout(remainingMs) }));
195
+ const sleep = io.sleep ?? (ms => new Promise<void>(resolve => setTimeout(resolve, ms)));
196
+ const now = io.now ?? Date.now;
197
+ const stdout = io.stdout ?? console;
198
+
199
+ if (!args.wait) {
200
+ // Default: exactly one identity-checked readiness probe. No wait deadline —
201
+ // bounded only by the probe's own default timeout (remainingMs=undefined so
202
+ // the production defaults keep their built-in 750ms ceiling; no semantic
203
+ // regression vs. the single-probe behavior).
204
+ const live = await find(undefined);
205
+ if (!live) {
206
+ report(args, false, "unreachable", null, null, stdout);
207
+ return 1;
208
+ }
209
+ const p = await probe(live.port, { hostname: live.hostname, expectedPid: live.pid ?? undefined }, undefined);
210
+ if (p?.ready) {
211
+ report(args, true, "ready", p.pid, live.port, stdout);
212
+ return 0;
213
+ }
214
+ const status: CliReadinessStatus = sanitizeProbeStatus(p?.status ?? null);
215
+ report(args, false, status, p?.pid ?? live.pid ?? null, live.port, stdout);
216
+ return 1;
217
+ }
218
+
219
+ // --wait: ONE loop bounded by a single deadline (default 45s, max 300s). The
220
+ // deadline is the single source of truth:
221
+ // - Before EVERY discovery and EVERY probe we compute remaining = deadline -
222
+ // now(); if it is non-positive we return code 1 WITHOUT starting that I/O.
223
+ // - After each awaited discovery/probe we re-read the clock; reached/exceeded
224
+ // means timeout wins (code 1), so a ready probe resolving at/after the
225
+ // deadline does NOT promote to ready.
226
+ // - No second discovery/probe may start once the deadline is reached.
227
+ // - Each sleep is capped to the positive remaining time. The remaining
228
+ // budget is also forwarded to the production discovery/probe defaults so
229
+ // their individual fetch waits are bounded by this same deadline.
230
+ const deadline = now() + args.timeoutSeconds * 1000;
231
+ let lastStatus: CliReadinessStatus = "unreachable";
232
+ let lastPid: number | null = null;
233
+ let lastPort: number | null = null;
234
+ for (;;) {
235
+ // Before discovery: refuse to start I/O when the deadline has elapsed.
236
+ const remainingBeforeFind = deadline - now();
237
+ if (remainingBeforeFind <= 0) {
238
+ report(args, false, lastStatus, lastPid, lastPort, stdout);
239
+ return 1;
240
+ }
241
+ const live = await find(remainingBeforeFind);
242
+ // After discovery: re-read the clock. Reached/exceeded → timeout wins and
243
+ // no probe is started (no second discovery/probe after the deadline).
244
+ let clock = now();
245
+ if (clock >= deadline) {
246
+ report(args, false, lastStatus, lastPid, lastPort, stdout);
247
+ return 1;
248
+ }
249
+ if (live) {
250
+ lastPort = live.port;
251
+ // Before probe: remaining derived from the post-discovery reading
252
+ // (guaranteed positive because clock < deadline above). This bounds the
253
+ // probe's fetch wait by the single deadline.
254
+ const remainingBeforeProbe = deadline - clock;
255
+ const p = await probe(
256
+ live.port,
257
+ { hostname: live.hostname, expectedPid: live.pid ?? undefined },
258
+ remainingBeforeProbe,
259
+ );
260
+ lastStatus = sanitizeProbeStatus(p?.status ?? null);
261
+ lastPid = p?.pid ?? live.pid ?? null;
262
+ // After every awaited probe: re-read the clock BEFORE terminal-failed or
263
+ // ready handling. Reached/exceeded → timeout wins (code 1) with the last
264
+ // sanitized status — a failed/ready probe resolving at/after the deadline
265
+ // must not take the terminal-failed shortcut or promote to ready.
266
+ clock = now();
267
+ if (clock >= deadline) {
268
+ report(args, false, lastStatus, lastPid, live.port, stdout);
269
+ return 1;
270
+ }
271
+ // `failed` is terminal only while still before the deadline: the startup
272
+ // sync has settled unsuccessfully, so waiting cannot change this gate.
273
+ // Report it immediately instead of consuming the rest of the timeout.
274
+ if (p?.status === "failed") {
275
+ report(args, false, "failed", lastPid, live.port, stdout);
276
+ return 1;
277
+ }
278
+ if (p?.ready) {
279
+ report(args, true, "ready", p.pid, live.port, stdout);
280
+ return 0;
281
+ }
282
+ } else {
283
+ // Discovery stopped finding the proxy: it may have exited since the last
284
+ // probe. Clear the cached status/identity so a stale `pending` (or its
285
+ // pid/port) cannot mislead a supervisor at timeout — the honest answer is
286
+ // `unreachable` with no identity.
287
+ lastStatus = "unreachable";
288
+ lastPid = null;
289
+ lastPort = null;
290
+ }
291
+ // Cap the sleep to the positive remaining time so we never sleep past the
292
+ // deadline. `clock` is the latest reading (post-discovery when no live
293
+ // proxy was found, post-probe otherwise) and is known to be < deadline.
294
+ const remainingForSleep = deadline - clock;
295
+ if (remainingForSleep <= 0) {
296
+ report(args, false, lastStatus, lastPid, lastPort, stdout);
297
+ return 1;
298
+ }
299
+ await sleep(Math.min(POLL_INTERVAL_MS, remainingForSleep));
300
+ }
301
+ }
@@ -0,0 +1,146 @@
1
+ import { readRuntimePort } from "../config";
2
+ import {
3
+ LOCAL_ATTESTATION_CHALLENGE_HEADER,
4
+ LOCAL_ATTESTATION_PROOF_HEADER,
5
+ createLocalAttestationChallenge,
6
+ verifyLocalAttestationProof,
7
+ } from "../lib/local-management-attestation";
8
+ import {
9
+ SYSTEM_RESTART_CAPABILITY_HEADER,
10
+ SYSTEM_RESTART_CAPABILITY_VERSION,
11
+ SYSTEM_RESTART_EXPECTED_PID_HEADER,
12
+ SYSTEM_RESTART_METHOD,
13
+ SYSTEM_RESTART_NONCE_HEADER,
14
+ SYSTEM_RESTART_PATH,
15
+ createSystemRestartCapability,
16
+ } from "../lib/system-restart-contract";
17
+ import {
18
+ findLiveProxy,
19
+ isOpencodexHealthz,
20
+ probeHostname,
21
+ type HealthzIdentity,
22
+ type LiveProxy,
23
+ } from "../server/proxy-liveness";
24
+ import type { ProxyRestartRequestOutcome } from "./tray-proxy";
25
+
26
+ export const SYSTEM_RESTART_REQUEST_TIMEOUT_MS = 5_000;
27
+ export const SYSTEM_RESTART_ATTESTATION_TIMEOUT_MS = 4_000;
28
+
29
+ export interface BoundSystemRestartDeps {
30
+ fetchImpl?: typeof fetch;
31
+ readRuntime?: typeof readRuntimePort;
32
+ findLive?: typeof findLiveProxy;
33
+ createChallenge?: () => string;
34
+ now?: () => number;
35
+ }
36
+
37
+ function rejected(code: string): ProxyRestartRequestOutcome {
38
+ return { accepted: false, uncertain: false, error: new Error(code) };
39
+ }
40
+
41
+ function uncertain(code: string): ProxyRestartRequestOutcome {
42
+ return { accepted: false, uncertain: true, error: new Error(code) };
43
+ }
44
+
45
+ function remaining(deadlineAt: number, now: () => number, cap: number): number {
46
+ return Math.max(0, Math.min(cap, deadlineAt - now()));
47
+ }
48
+
49
+ function sameRestartTarget(expected: LiveProxy, observed: LiveProxy | null): boolean {
50
+ return expected.pid !== null
51
+ && observed?.source === "runtime"
52
+ && observed.pid === expected.pid
53
+ && observed.port === expected.port;
54
+ }
55
+
56
+ /**
57
+ * Send one restart request to the exact runtime proxy observed by the caller.
58
+ *
59
+ * No reusable admin credential is sent. After the listener proves possession of
60
+ * its per-process runtime secret, the client derives a capability bound to this
61
+ * method, path, PID, and port. The expected PID is repeated so a replacement that
62
+ * wins the port between proof and POST rejects the request.
63
+ */
64
+ export async function requestBoundSystemRestart(
65
+ target: LiveProxy,
66
+ deadlineAt: number,
67
+ deps: BoundSystemRestartDeps = {},
68
+ ): Promise<ProxyRestartRequestOutcome> {
69
+ if (target.source !== "runtime" || target.pid === null) return rejected("restart_target_unattested");
70
+
71
+ const now = deps.now ?? Date.now;
72
+ const readRuntime = deps.readRuntime ?? readRuntimePort;
73
+ const runtime = readRuntime(target.pid);
74
+ if (!runtime?.attestationSecret || runtime.pid !== target.pid || runtime.port !== target.port) {
75
+ return rejected("restart_target_runtime_mismatch");
76
+ }
77
+
78
+ const attestationBudget = remaining(deadlineAt, now, SYSTEM_RESTART_ATTESTATION_TIMEOUT_MS);
79
+ if (attestationBudget <= 0) return rejected("restart_deadline_expired");
80
+
81
+ const fetchImpl = deps.fetchImpl ?? fetch;
82
+ const challenge = (deps.createChallenge ?? createLocalAttestationChallenge)();
83
+ const baseUrl = `http://${probeHostname(target.hostname)}:${target.port}`;
84
+ let proofResponse: Response;
85
+ try {
86
+ proofResponse = await fetchImpl(`${baseUrl}/healthz`, {
87
+ headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge },
88
+ signal: AbortSignal.timeout(attestationBudget),
89
+ });
90
+ } catch {
91
+ return rejected("restart_attestation_unreachable");
92
+ }
93
+ const body = await proofResponse.json().catch(() => null) as HealthzIdentity | null;
94
+ const proof = proofResponse.headers.get(LOCAL_ATTESTATION_PROOF_HEADER);
95
+ if (
96
+ !proofResponse.ok
97
+ || !isOpencodexHealthz(body)
98
+ || body?.pid !== target.pid
99
+ || !verifyLocalAttestationProof(runtime.attestationSecret, challenge, target.pid, target.port, proof)
100
+ ) {
101
+ return rejected("restart_attestation_failed");
102
+ }
103
+ if (body.restartCapability !== SYSTEM_RESTART_CAPABILITY_VERSION) {
104
+ // A pre-update proxy accepts only the reusable management credential and cannot
105
+ // bind the operation to the attested PID. Refuse before POST rather than weakening
106
+ // the exact-process contract or replaying a stop/start transaction.
107
+ return rejected("restart_capability_unsupported");
108
+ }
109
+
110
+ let observed: LiveProxy | null;
111
+ try {
112
+ observed = await (deps.findLive ?? findLiveProxy)({ deadlineAt, nowFn: now });
113
+ } catch {
114
+ return rejected("restart_target_recheck_failed");
115
+ }
116
+ if (!sameRestartTarget(target, observed)) return rejected("restart_target_changed");
117
+
118
+ const capability = createSystemRestartCapability(
119
+ runtime.attestationSecret,
120
+ challenge,
121
+ SYSTEM_RESTART_METHOD,
122
+ SYSTEM_RESTART_PATH,
123
+ target.pid,
124
+ target.port,
125
+ );
126
+ if (!capability) return rejected("restart_capability_unavailable");
127
+
128
+ const requestBudget = remaining(deadlineAt, now, SYSTEM_RESTART_REQUEST_TIMEOUT_MS);
129
+ if (requestBudget <= 0) return rejected("restart_deadline_expired");
130
+ try {
131
+ const response = await fetchImpl(`${baseUrl}${SYSTEM_RESTART_PATH}`, {
132
+ method: SYSTEM_RESTART_METHOD,
133
+ headers: {
134
+ [SYSTEM_RESTART_EXPECTED_PID_HEADER]: String(target.pid),
135
+ [SYSTEM_RESTART_NONCE_HEADER]: challenge,
136
+ [SYSTEM_RESTART_CAPABILITY_HEADER]: capability,
137
+ },
138
+ signal: AbortSignal.timeout(requestBudget),
139
+ });
140
+ return response.ok ? { accepted: true } : rejected(`restart_request_http_${response.status}`);
141
+ } catch {
142
+ // The server may have accepted the restart before the response connection failed.
143
+ // The coordinator observes the original PID for replacement instead of replaying.
144
+ return uncertain("restart_request_outcome_unknown");
145
+ }
146
+ }
@@ -8,6 +8,8 @@ export interface TrayProxyServiceState {
8
8
 
9
9
  export interface TrayProxyStartIo {
10
10
  findLive: () => Promise<TrayProxyLive | null>;
11
+ /** Normal Start is idempotent; restart fallback refuses a target that reappeared. */
12
+ existingIsSuccess?: boolean;
11
13
  diagnoseService: () => TrayProxyServiceState;
12
14
  startService: () => void | Promise<void>;
13
15
  startDirect: () => void | Promise<void>;
@@ -16,10 +18,101 @@ export interface TrayProxyStartIo {
16
18
  error: (message: string) => void;
17
19
  }
18
20
 
21
+ export interface ProxyRestartLive {
22
+ pid: number | null;
23
+ port: number;
24
+ hostname?: string;
25
+ source: "runtime" | "config";
26
+ }
27
+
28
+ export type ProxyRestartRequestOutcome =
29
+ | { accepted: true }
30
+ | { accepted: false; uncertain: boolean; error?: unknown };
31
+
32
+ export type ProxyRestartResult =
33
+ | { ok: true; mode: "started" }
34
+ | { ok: true; mode: "skipped" }
35
+ | { ok: true; mode: "restarted"; live: ProxyRestartLive }
36
+ | { ok: false; phase: "start" | "identity" | "request" | "replacement"; error?: unknown };
37
+
38
+ export type ProxyRestartDiscovery =
39
+ | { status: "live"; live: ProxyRestartLive }
40
+ | { status: "absent" }
41
+ | { status: "uncertain"; error?: unknown };
42
+
43
+ export interface ProxyRestartDiscoveryIo {
44
+ findLive: () => Promise<ProxyRestartLive | null>;
45
+ waitBetweenChecks?: () => Promise<void>;
46
+ expired?: () => boolean;
47
+ }
48
+
49
+ export interface ProxyRestartIo {
50
+ findLive: () => Promise<ProxyRestartDiscovery>;
51
+ startWhenStopped: () => boolean | "skipped" | Promise<boolean | "skipped">;
52
+ requestInPlaceRestart: (
53
+ previous: ProxyRestartLive,
54
+ ) => ProxyRestartRequestOutcome | Promise<ProxyRestartRequestOutcome>;
55
+ waitForReplacement: (previous: ProxyRestartLive) => Promise<ProxyRestartLive | null>;
56
+ }
57
+
58
+ /**
59
+ * Confirm absence twice before restart is allowed to select a start-only path.
60
+ * A live target appearing during confirmation is uncertainty, not success: the
61
+ * caller must not claim it restarted a process it never asked to restart.
62
+ */
63
+ export async function discoverStableProxyForRestart(
64
+ io: ProxyRestartDiscoveryIo,
65
+ ): Promise<ProxyRestartDiscovery> {
66
+ let first: ProxyRestartLive | null;
67
+ try {
68
+ first = await io.findLive();
69
+ } catch (error) {
70
+ return { status: "uncertain", error };
71
+ }
72
+ if (first) return { status: "live", live: first };
73
+ if (io.expired?.()) {
74
+ return { status: "uncertain", error: new Error("restart_discovery_deadline_expired") };
75
+ }
76
+
77
+ await (io.waitBetweenChecks ?? (() => Bun.sleep(100)))();
78
+ let second: ProxyRestartLive | null;
79
+ try {
80
+ second = await io.findLive();
81
+ } catch (error) {
82
+ return { status: "uncertain", error };
83
+ }
84
+ if (second) {
85
+ return {
86
+ status: "uncertain",
87
+ error: new Error("restart_target_appeared_during_absence_confirmation"),
88
+ };
89
+ }
90
+ if (io.expired?.()) {
91
+ return { status: "uncertain", error: new Error("restart_discovery_deadline_expired") };
92
+ }
93
+ return { status: "absent" };
94
+ }
95
+
96
+ export function isProxyReplacement(
97
+ previous: ProxyRestartLive,
98
+ candidate: ProxyRestartLive | null,
99
+ ): candidate is ProxyRestartLive & { pid: number } {
100
+ return previous.pid !== null
101
+ && candidate?.pid !== null
102
+ && candidate?.pid !== undefined
103
+ && candidate.source === "runtime"
104
+ && candidate.port === previous.port
105
+ && candidate.pid !== previous.pid;
106
+ }
107
+
19
108
  /** Side-effect coordinator for the tray's fixed proxy-start action. */
20
109
  export async function runTrayProxyStart(io: TrayProxyStartIo): Promise<boolean> {
21
110
  const live = await io.findLive();
22
111
  if (live) {
112
+ if (io.existingIsSuccess === false) {
113
+ io.error("Proxy appeared while restart was confirming absence; no start was attempted.");
114
+ return false;
115
+ }
23
116
  io.info(`Proxy already running on port ${live.port}.`);
24
117
  return true;
25
118
  }
@@ -43,10 +136,64 @@ export async function runTrayProxyStart(io: TrayProxyStartIo): Promise<boolean>
43
136
  return true;
44
137
  }
45
138
 
46
- export async function runTrayProxyRestart(io: {
47
- stop: () => boolean | Promise<boolean>;
48
- start: () => boolean | Promise<boolean>;
49
- }): Promise<boolean> {
50
- if (!await io.stop()) return false;
51
- return io.start();
139
+ /**
140
+ * Shared restart transaction for both `ocx restart` and the Windows tray.
141
+ *
142
+ * A live proxy restarts itself through POST /api/system/restart. That lifecycle owns
143
+ * drain, supervisor handoff, exact replacement identity, and managed-routing
144
+ * preservation. Re-implementing restart as `stop` + `start` here races a late service
145
+ * child and lets ordinary /api/stop restore native routing between the two halves.
146
+ * When no proxy is live there is nothing to recycle, so restart degrades to the
147
+ * caller's normal start path.
148
+ */
149
+ export async function runProxyRestart(io: ProxyRestartIo): Promise<ProxyRestartResult> {
150
+ let discovery: ProxyRestartDiscovery;
151
+ try {
152
+ discovery = await io.findLive();
153
+ } catch (error) {
154
+ return { ok: false, phase: "request", error };
155
+ }
156
+
157
+ if (discovery.status === "uncertain") {
158
+ return { ok: false, phase: "request", error: discovery.error };
159
+ }
160
+
161
+ if (discovery.status === "absent") {
162
+ try {
163
+ const started = await io.startWhenStopped();
164
+ if (started === "skipped") return { ok: true, mode: "skipped" };
165
+ return started ? { ok: true, mode: "started" } : { ok: false, phase: "start" };
166
+ } catch (error) {
167
+ return { ok: false, phase: "start", error };
168
+ }
169
+ }
170
+
171
+ const previous = discovery.live;
172
+
173
+ if (previous.pid === null || previous.source !== "runtime") {
174
+ return { ok: false, phase: "identity" };
175
+ }
176
+
177
+ let request: ProxyRestartRequestOutcome;
178
+ try {
179
+ request = await io.requestInPlaceRestart(previous);
180
+ } catch (error) {
181
+ // The request may have reached the proxy before the response connection failed.
182
+ // Keep observing the original identity; never replay or fall back to stop/start.
183
+ request = { accepted: false, uncertain: true, error };
184
+ }
185
+
186
+ if (!request.accepted && !request.uncertain) {
187
+ return { ok: false, phase: "request", error: request.error };
188
+ }
189
+
190
+ try {
191
+ const replacement = await io.waitForReplacement(previous);
192
+ if (replacement) return { ok: true, mode: "restarted", live: replacement };
193
+ return request.accepted
194
+ ? { ok: false, phase: "replacement" }
195
+ : { ok: false, phase: "request", error: request.error };
196
+ } catch (error) {
197
+ return { ok: false, phase: "replacement", error };
198
+ }
52
199
  }