@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.
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/index-Bk-PN-70.css +1 -0
- package/gui/dist/assets/index-BynIEIV-.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/live-transport.ts +11 -0
- package/src/adapters/cursor/native-exec-fs.ts +9 -6
- package/src/adapters/cursor/native-exec.ts +4 -2
- package/src/adapters/cursor/protobuf-events.ts +176 -4
- package/src/adapters/cursor/request-builder.ts +15 -4
- package/src/adapters/cursor/tool-definitions.ts +118 -2
- package/src/adapters/google.ts +15 -5
- package/src/adapters/openai-chat.ts +24 -2
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +9 -5
- package/src/chat/outbound.ts +4 -3
- package/src/claude/desktop-3p.ts +222 -2
- package/src/claude/outbound.ts +15 -6
- package/src/cli/account-api.ts +4 -0
- package/src/cli/account-extended.ts +112 -0
- package/src/cli/account.ts +23 -6
- package/src/cli/claude-desktop.ts +26 -3
- package/src/cli/config-command.ts +9 -0
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +277 -55
- package/src/cli/models.ts +5 -1
- package/src/cli/provider.ts +8 -2
- package/src/cli/ready.ts +301 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/clients/config-export.ts +12 -19
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/auth-api.ts +83 -0
- package/src/codex/auth-context.ts +5 -2
- package/src/codex/catalog/provider-fetch.ts +11 -0
- package/src/codex/catalog/sync.ts +23 -1
- package/src/codex/codex-write-lock.ts +16 -4
- package/src/codex/desired-state.ts +37 -4
- package/src/codex/history-job.ts +15 -5
- package/src/codex/history-provider.ts +31 -14
- package/src/codex/history-worker.ts +28 -4
- package/src/codex/inject-coordination.ts +13 -1
- package/src/codex/inject.ts +360 -66
- package/src/codex/internal/history-writer.ts +1 -1
- package/src/codex/native-main-lock-file.ts +5 -1
- package/src/codex/native-main-owner.ts +17 -3
- package/src/codex/native-profile-manager.ts +19 -0
- package/src/codex/native-profile-startup.ts +8 -0
- package/src/codex/native-residue.ts +140 -27
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/refresh.ts +7 -0
- package/src/codex/routing.ts +177 -36
- package/src/codex/subagent-model-fallback.ts +34 -4
- package/src/codex/sync.ts +61 -0
- package/src/codex/upstream-host-health.ts +329 -31
- package/src/combos/request.ts +2 -0
- package/src/config.ts +221 -2
- package/src/images/loop.ts +1 -1
- package/src/integrations/native/ownership-preflight.ts +39 -2
- package/src/lib/bun-stream-caps.ts +3 -3
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/windows-secret-acl.ts +141 -39
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +18 -2
- package/src/oauth/key-providers.ts +12 -0
- package/src/providers/derive.ts +54 -2
- package/src/providers/free-directory.ts +6 -5
- package/src/providers/model-discovery.ts +9 -3
- package/src/providers/quota.ts +592 -0
- package/src/providers/registry.ts +316 -13
- package/src/responses/parser.ts +26 -10
- package/src/responses/reasoning-replay-cache.ts +1 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +2 -1
- package/src/server/auth-cors.ts +44 -13
- package/src/server/chat-completions.ts +0 -4
- package/src/server/claude-messages.ts +73 -15
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/index.ts +328 -111
- package/src/server/lifecycle.ts +36 -0
- package/src/server/management/agent-settings-routes.ts +147 -56
- package/src/server/management/config-routes.ts +7 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/native-integration-routes.ts +199 -20
- package/src/server/management/provider-routes.ts +41 -0
- package/src/server/management/routing-profile-routes.ts +234 -5
- package/src/server/management/system-restart.ts +12 -10
- package/src/server/management/system-routes.ts +20 -0
- package/src/server/management-auth.ts +51 -3
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +129 -4
- package/src/server/readiness.ts +99 -0
- package/src/server/relay.ts +113 -97
- package/src/server/request-log.ts +10 -4
- package/src/server/responses/compact.ts +107 -12
- package/src/server/responses/core.ts +220 -39
- package/src/server/responses-item-id-repair.ts +22 -3
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +25 -14
- package/src/server/ws-bridge.ts +27 -22
- package/src/service-manager-probe.ts +520 -10
- package/src/service.ts +134 -2
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +74 -9
- package/src/types.ts +68 -2
- package/src/update/index.ts +12 -0
- package/src/update/job.ts +392 -18
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/usage/log.ts +1 -1
- package/src/vision/index.ts +77 -2
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-BKVqyYqT.js +0 -70
- package/gui/dist/assets/index-Ca_3269W.css +0 -1
package/src/cli/ready.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/tray-proxy.ts
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
}
|