@bitkyc08/opencodex 2.90.0 → 2.92.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/bin/ocx.mjs +37 -3
- package/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
- package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
- package/gui/dist/assets/index-BlFE_VB_.js +94 -0
- package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
- package/gui/dist/index.html +1 -1
- package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
- package/package.json +4 -3
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/claude-cli/usage-admission.ts +14 -0
- package/src/adapters/google-vertex-endpoint.ts +9 -1
- package/src/adapters/google-vertex-key-project.ts +223 -0
- package/src/adapters/google.ts +13 -3
- package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/adapters/openai-responses/request-strips.ts +92 -0
- package/src/adapters/openai-responses/transport.ts +6 -0
- package/src/adapters/registry.ts +5 -0
- package/src/adapters/workbuddy-schema.ts +61 -0
- package/src/adapters/workbuddy.ts +68 -0
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +1 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +47 -8
- package/src/chat/reasoning-details.ts +137 -0
- package/src/cli/capabilities-agents-routing.ts +53 -3
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-cli-update.ts +8 -0
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/combo-result.ts +6 -0
- package/src/cli/combo-stats.ts +3 -0
- package/src/cli/combo.ts +66 -42
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/launcher-context.ts +49 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +123 -35
- package/src/cli/provider.ts +46 -3
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/cli/usage-compare.ts +68 -12
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-ingress.ts +1 -1
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-readiness.ts +97 -0
- package/src/client/link-relay-transport.ts +84 -9
- package/src/client/link-relay.ts +98 -15
- package/src/client/link-tunnel.ts +45 -18
- package/src/client/machine-listener.ts +103 -3
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +155 -25
- package/src/codex/account-store.ts +17 -3
- package/src/codex/app-server-restart-service.ts +6 -5
- package/src/codex/auth-context.ts +10 -6
- package/src/codex/catalog/effort.ts +9 -2
- package/src/codex/catalog/gather-capture.ts +4 -2
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/provider-models.ts +14 -8
- package/src/codex/chatgpt-refresh-failure.ts +6 -2
- package/src/codex/cli-install-provenance.ts +418 -0
- package/src/codex/cli-installation-targets.ts +5 -1
- package/src/codex/cli-update-plan.ts +43 -16
- package/src/codex/desktop-app/picker-freshness.ts +10 -8
- package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
- package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/pool-refresh-backoff.ts +3 -1
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/codex/user-identity.ts +35 -6
- package/src/codex/windows-current-sid.ts +162 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/jev-quota-activation.ts +5 -1
- package/src/combos/jev-quota-route.ts +1 -1
- package/src/combos/jev-quota.ts +9 -1
- package/src/combos/jev-session-pin-config.ts +51 -0
- package/src/combos/jev.ts +6 -0
- package/src/combos/resolve.ts +12 -0
- package/src/combos/types.ts +19 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -0
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +375 -195
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/decision-quota-sink.ts +10 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/lib/translator-budget.ts +4 -4
- package/src/lib/upstream-retry.ts +4 -1
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/diagnostics.ts +193 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/relay-auth.ts +6 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +63 -2
- package/src/link/supervisor.ts +45 -12
- package/src/link/tunnel-history.ts +109 -0
- package/src/link/tunnel-state.ts +80 -17
- package/src/messaging/remote-files.ts +24 -1
- package/src/oauth/generic-account-failover.ts +91 -7
- package/src/oauth/index.ts +16 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/model-discovery-auth.ts +5 -0
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +6 -2
- package/src/oauth/token-guardian.ts +7 -0
- package/src/oauth/types.ts +9 -0
- package/src/oauth/workbuddy.ts +139 -0
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- package/src/providers/antigravity-quota-identity.ts +37 -0
- package/src/providers/base-url-choices.ts +8 -5
- package/src/providers/derive.ts +23 -3
- package/src/providers/mistral-transport.ts +37 -4
- package/src/providers/model-discovery.ts +2 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/quota/account-cache.ts +3 -2
- package/src/providers/quota/antigravity.ts +27 -22
- package/src/providers/quota/report-cache.ts +13 -4
- package/src/providers/quota/vendor-probes-key.ts +60 -0
- package/src/providers/quota/workbuddy.ts +83 -0
- package/src/providers/quota-decision-antigravity.ts +64 -0
- package/src/providers/quota-decision-publication.ts +11 -0
- package/src/providers/quota-decision-snapshot.ts +21 -4
- package/src/providers/quota-types.ts +2 -0
- package/src/providers/quota.ts +3 -1
- package/src/providers/registry/entries-core.ts +9 -0
- package/src/providers/registry/entries-extended.ts +20 -7
- package/src/providers/registry/model-ids.ts +3 -0
- package/src/providers/registry/model-seeds.ts +27 -28
- package/src/providers/registry/types.ts +19 -1
- package/src/providers/resolved-model-policy.ts +4 -4
- package/src/providers/routed-config.ts +15 -0
- package/src/providers/stale-model-roster-migration.ts +83 -15
- package/src/providers/workbuddy-credit-balance.ts +81 -0
- package/src/providers/workbuddy-models.ts +37 -0
- package/src/providers/workbuddy.ts +60 -0
- package/src/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-replay-cache.ts +18 -14
- package/src/router.ts +2 -0
- package/src/routing/analytics.ts +2 -3
- package/src/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +7 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +22 -2
- package/src/server/chat-native-reasoning.ts +10 -3
- package/src/server/chat-native.ts +13 -2
- package/src/server/claude-messages.ts +3 -0
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +20 -7
- package/src/server/index/optional-listeners.ts +11 -2
- package/src/server/index/serve-options.ts +19 -2
- package/src/server/live.ts +5 -0
- package/src/server/local-management-read-client.ts +3 -1
- package/src/server/management/combo-routes.ts +10 -4
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +16 -2
- package/src/server/management/provider-routes.ts +1 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.ts +2 -1
- package/src/server/port-reclaim.ts +51 -9
- package/src/server/request-log-conversation.ts +49 -0
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
- package/src/server/responses/compact.ts +12 -4
- package/src/server/responses/core-auth.ts +11 -1
- package/src/server/responses/core-combo.ts +125 -32
- package/src/server/responses/core-lifetime.ts +2 -2
- package/src/server/responses/core-normalize.ts +6 -3
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +2 -1
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/jev-combo-choice.ts +39 -0
- package/src/server/responses/jev-session-pin.ts +203 -0
- package/src/server/responses/main-hard-lock-routing.ts +54 -2
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/request-prepare.ts +19 -6
- package/src/server/responses/request-transport.ts +25 -6
- package/src/server/responses/run-turn-execution.ts +2 -4
- package/src/server/responses/sidecar-execution.ts +2 -4
- package/src/server/responses-phase-inference.ts +267 -0
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/sse-payload-rewrite.ts +14 -1
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/definition.ts +20 -8
- package/src/service/diagnostics.ts +20 -6
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +13 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +9 -1
- package/src/types/request.ts +1 -0
- package/src/types/wire.ts +1 -0
- package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/usage/jev-stats.ts +54 -14
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/index-TmaHa54u.js +0 -94
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
|
@@ -4,9 +4,12 @@ import { Readable } from "node:stream";
|
|
|
4
4
|
import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
|
|
5
5
|
import { serviceApiTokenFingerprint } from "../lib/service-secrets";
|
|
6
6
|
import { clearableDeadline } from "../lib/abort";
|
|
7
|
+
import { packageVersion } from "../lib/package-version";
|
|
8
|
+
import { linkPeerReportHeaders } from "../link/peer-report";
|
|
9
|
+
import { buildPeerFeatures } from "../remote/features";
|
|
7
10
|
import {
|
|
8
11
|
LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION,
|
|
9
|
-
LINK_RELAY_SESSION_HEADER, linkRelayChallenge, linkRelayProofMatches,
|
|
12
|
+
LINK_RELAY_PROBE_HEADER, LINK_RELAY_SESSION_HEADER, linkRelayChallenge, linkRelayProofMatches,
|
|
10
13
|
} from "../link/relay-auth";
|
|
11
14
|
|
|
12
15
|
export interface BoundLinkRelayTarget {
|
|
@@ -24,6 +27,14 @@ export class LinkRelayAuthenticationError extends Error {
|
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
/** Only the credential-free preflight deadline expired; no data request has begun. */
|
|
31
|
+
export class LinkRelayAuthTimeoutError extends Error {
|
|
32
|
+
constructor() {
|
|
33
|
+
super("link relay authentication timed out");
|
|
34
|
+
this.name = "LinkRelayAuthTimeoutError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
/** One physical connection, ever. A closed keep-alive socket cannot trigger a silent reconnect. */
|
|
28
39
|
class SingleConnectionAgent extends Agent {
|
|
29
40
|
socket: Socket | undefined;
|
|
@@ -148,19 +159,78 @@ async function responseOnConnection(incoming: IncomingMessage, agent: SingleConn
|
|
|
148
159
|
}, { highWaterMark: 0 }), { status, headers });
|
|
149
160
|
}
|
|
150
161
|
|
|
151
|
-
|
|
152
|
-
|
|
162
|
+
let peerReport: Record<string, string> | undefined;
|
|
163
|
+
|
|
164
|
+
/** The credential-free challenge for one connection; the proof binds this nonce to the link key. */
|
|
165
|
+
function relayChallenge(target: BoundLinkRelayTarget, origin: URL) {
|
|
166
|
+
const challenge = linkRelayChallenge(serviceApiTokenFingerprint(target.admissionKey), target.apiKeyId!, target.linkId!);
|
|
167
|
+
const probe = new URL(LINK_RELAY_AUTH_PATH, origin);
|
|
168
|
+
probe.search = new URLSearchParams({ version: LINK_RELAY_AUTH_VERSION, key: target.apiKeyId!,
|
|
169
|
+
link: target.linkId!, nonce: challenge.nonce, proof: challenge.caller }).toString();
|
|
170
|
+
// The challenge also carries this Child's version and features for the Home's link status.
|
|
171
|
+
peerReport ??= linkPeerReportHeaders(packageVersion("0.0.0"), buildPeerFeatures());
|
|
172
|
+
return { challenge, probe, headers: peerReport };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The outcome of the credential-free preflight alone: `ok` when the Home answered with a proof
|
|
177
|
+
* only the holder of the link key can make, `unrecognized` for a peer without the route (404),
|
|
178
|
+
* `auth_failed` for any other answer, and `unreachable` when nothing answered in time.
|
|
179
|
+
*/
|
|
180
|
+
export type LinkRelayPreflightResult = "ok" | "unreachable" | "auth_failed" | "unrecognized";
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Run only the preflight a relayed request starts with, on its own connection, and close it. It
|
|
184
|
+
* sends no link key, no data request and no body; the Child's readiness uses it to learn whether
|
|
185
|
+
* the Home answers through the tunnel right now. It is marked probe-only, so the Home reserves no
|
|
186
|
+
* session for a data request that never comes. The response body is never read.
|
|
187
|
+
*/
|
|
188
|
+
export async function probeLinkRelayPreflight(
|
|
189
|
+
target: BoundLinkRelayTarget, options: { timeoutMs?: number } = {},
|
|
190
|
+
): Promise<LinkRelayPreflightResult> {
|
|
191
|
+
const timeoutMs = options.timeoutMs ?? LINK_RELAY_AUTH_TIMEOUT_MS;
|
|
192
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new RangeError("invalid link relay preflight timeout");
|
|
193
|
+
if (!target.linkId || !target.apiKeyId) return "auth_failed";
|
|
194
|
+
const origin = new URL(`http://127.0.0.1:${target.tunnelPort}`);
|
|
195
|
+
let request: ReturnType<typeof relayChallenge>;
|
|
196
|
+
try { request = relayChallenge(target, origin); } catch { return "auth_failed"; }
|
|
197
|
+
const agent = new SingleConnectionAgent({ keepAlive: false, maxSockets: 1, maxFreeSockets: 0 });
|
|
198
|
+
const deadline = clearableDeadline(timeoutMs);
|
|
199
|
+
try {
|
|
200
|
+
const response = await requestOnConnection(request.probe, {
|
|
201
|
+
method: "GET", signal: deadline.signal, headers: { ...request.headers, [LINK_RELAY_PROBE_HEADER]: "1" },
|
|
202
|
+
}, agent);
|
|
203
|
+
const proof = response.headers["x-opencodex-link-proof"];
|
|
204
|
+
if (response.statusCode === 204 && typeof proof === "string" && linkRelayProofMatches(proof, request.challenge.expected)) return "ok";
|
|
205
|
+
return response.statusCode === 404 ? "unrecognized" : "auth_failed";
|
|
206
|
+
} catch {
|
|
207
|
+
return "unreachable";
|
|
208
|
+
} finally {
|
|
209
|
+
deadline.clear();
|
|
210
|
+
await agent.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Authenticate and stream one request on the SAME socket; never fall back to an ordinary fetch.
|
|
216
|
+
* LinkRelayAuthTimeoutError certifies that only the credential-free preflight expired before
|
|
217
|
+
* data-request construction. Caller aborts and failures after that boundary keep their own errors.
|
|
218
|
+
*/
|
|
219
|
+
export async function fetchBoundLinkRelay(
|
|
220
|
+
target: BoundLinkRelayTarget, destination: string, init: RequestInit,
|
|
221
|
+
options: { authTimeoutMs?: number } = {},
|
|
222
|
+
): Promise<Response> {
|
|
223
|
+
const authTimeoutMs = options.authTimeoutMs ?? LINK_RELAY_AUTH_TIMEOUT_MS;
|
|
224
|
+
if (!Number.isFinite(authTimeoutMs) || authTimeoutMs <= 0) throw new RangeError("invalid link relay auth timeout");
|
|
153
225
|
if (!target.linkId || !target.apiKeyId) throw new LinkRelayAuthenticationError();
|
|
154
226
|
const url = new URL(destination);
|
|
155
227
|
if (url.origin !== `http://127.0.0.1:${target.tunnelPort}`) throw new LinkRelayAuthenticationError();
|
|
156
|
-
const challenge =
|
|
157
|
-
const probe = new URL(LINK_RELAY_AUTH_PATH, url);
|
|
158
|
-
probe.search = new URLSearchParams({ version: LINK_RELAY_AUTH_VERSION, key: target.apiKeyId,
|
|
159
|
-
link: target.linkId, nonce: challenge.nonce, proof: challenge.caller }).toString();
|
|
228
|
+
const { challenge, probe, headers: preflightHeaders } = relayChallenge(target, url);
|
|
160
229
|
const agent = new SingleConnectionAgent({ keepAlive: true, maxSockets: 1, maxFreeSockets: 1 });
|
|
161
|
-
const deadline = clearableDeadline(
|
|
230
|
+
const deadline = clearableDeadline(authTimeoutMs, init.signal ?? undefined);
|
|
231
|
+
let dataRequestStarted = false;
|
|
162
232
|
try {
|
|
163
|
-
const response = await requestOnConnection(probe, { method: "GET", signal: deadline.signal }, agent);
|
|
233
|
+
const response = await requestOnConnection(probe, { method: "GET", signal: deadline.signal, headers: preflightHeaders }, agent);
|
|
164
234
|
const socket = agent.socket;
|
|
165
235
|
const proof = response.headers["x-opencodex-link-proof"];
|
|
166
236
|
if (response.statusCode !== 204 || typeof proof !== "string" || !linkRelayProofMatches(proof, challenge.expected)) {
|
|
@@ -170,15 +240,20 @@ export async function fetchBoundLinkRelay(target: BoundLinkRelayTarget, destinat
|
|
|
170
240
|
for await (const chunk of response) {
|
|
171
241
|
if (chunk.length) throw new LinkRelayAuthenticationError();
|
|
172
242
|
}
|
|
243
|
+
deadline.signal.throwIfAborted();
|
|
173
244
|
deadline.clear();
|
|
174
245
|
init.signal?.throwIfAborted();
|
|
175
246
|
if (!socket || socket.destroyed || socket !== agent.socket) throw new LinkRelayAuthenticationError();
|
|
176
247
|
const headers = new Headers(init.headers);
|
|
177
248
|
headers.set(LINK_RELAY_SESSION_HEADER, challenge.nonce);
|
|
249
|
+
dataRequestStarted = true;
|
|
178
250
|
const incoming = await requestOnConnection(url, { ...init, headers }, agent, socket);
|
|
179
251
|
return await responseOnConnection(incoming, agent, init.method ?? "GET");
|
|
180
252
|
} catch (error) {
|
|
253
|
+
const authDeadlineExpired = !dataRequestStarted && deadline.didExpire()
|
|
254
|
+
&& !(error instanceof LinkRelayAuthenticationError);
|
|
181
255
|
await agent.close();
|
|
256
|
+
if (authDeadlineExpired && !init.signal?.aborted) throw new LinkRelayAuthTimeoutError();
|
|
182
257
|
throw error;
|
|
183
258
|
} finally { deadline.clear(); }
|
|
184
259
|
}
|
package/src/client/link-relay.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { fetchBoundLinkRelay, LinkRelayAuthenticationError, LinkRelayAuthTimeoutError } from "./link-relay-transport";
|
|
3
|
+
import { homeUnreachableBody } from "./home-unreachable";
|
|
2
4
|
import {
|
|
3
5
|
boundedRelayResponseStream,
|
|
4
6
|
filterRelayHeaders,
|
|
5
7
|
headersWithinLimit,
|
|
6
8
|
validateHubRelayRequestHeaders,
|
|
7
9
|
} from "./hub-relay";
|
|
10
|
+
import type { LinkDiagnosticRecord, LinkDiagnosticSink } from "../link/diagnostics";
|
|
8
11
|
import { linkRouteAllowed } from "../link/routes";
|
|
9
12
|
import { isLinkPort } from "../link/ports";
|
|
10
13
|
import { resolveInboundBodyLimitBytes } from "../server/request-decompress";
|
|
@@ -25,14 +28,23 @@ export interface LinkRelayClock {
|
|
|
25
28
|
clearTimeout: typeof clearTimeout;
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
/** A fresh admission verdict plus optional diagnostic evidence; metadata never grants admission. */
|
|
32
|
+
export interface LinkAdmissionResult {
|
|
33
|
+
kind: "match" | "mismatch" | "lookup_error" | "deadline" | "stale" | "identity_unknown" | "identity_mismatch" | "unavailable";
|
|
34
|
+
elapsedMs?: number;
|
|
35
|
+
generation?: number;
|
|
36
|
+
state?: string;
|
|
37
|
+
checkId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
/**
|
|
29
41
|
* The Child's tunnel as the relay sees it (the client link supervisor implements it). A request
|
|
30
42
|
* waits on it only while the tunnel is being (re)established. Every relayed fetch waits for a
|
|
31
43
|
* fresh bounded asynchronous ownership proof, including after a reconnect or refused retry.
|
|
32
44
|
*/
|
|
33
45
|
export interface LinkTunnelGate {
|
|
34
|
-
/**
|
|
35
|
-
connected():
|
|
46
|
+
/** Admission and its evidence are returned together; only match permits a fetch. */
|
|
47
|
+
connected(context?: { requestId?: string }): LinkAdmissionResult | Promise<LinkAdmissionResult>;
|
|
36
48
|
/** True while the tunnel is connecting or reconnecting. */
|
|
37
49
|
pending(): boolean;
|
|
38
50
|
/**
|
|
@@ -50,12 +62,13 @@ export interface LinkTunnelGate {
|
|
|
50
62
|
* supervisor, and a relay with no gate at all still refuses every request.
|
|
51
63
|
*/
|
|
52
64
|
export const HOME_INITIATED_LINK_TUNNEL: LinkTunnelGate = {
|
|
53
|
-
connected: () =>
|
|
65
|
+
connected: () => ({ kind: "match" }),
|
|
54
66
|
pending: () => false,
|
|
55
67
|
waitForConnected: async () => false,
|
|
56
68
|
};
|
|
57
69
|
|
|
58
70
|
export interface LinkRelayDeps {
|
|
71
|
+
diagnostic?: LinkDiagnosticSink;
|
|
59
72
|
/** Trusted complete-send test seam. Production always uses the connection-bound transport. */
|
|
60
73
|
fetchImpl?: typeof fetch;
|
|
61
74
|
clock?: LinkRelayClock;
|
|
@@ -112,6 +125,30 @@ function jsonError(status: number, error: string, retry = false): Response {
|
|
|
112
125
|
return Response.json({ error }, { status, headers });
|
|
113
126
|
}
|
|
114
127
|
|
|
128
|
+
/** A retryable 503 for a Home this Child cannot reach (OCX-11 `home_unreachable`). */
|
|
129
|
+
function homeUnreachable(error: string): Response {
|
|
130
|
+
return Response.json(homeUnreachableBody(error), {
|
|
131
|
+
status: 503,
|
|
132
|
+
headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The peer answered but the connection-bound relay authentication failed. That is not a Home
|
|
138
|
+
* outage (an unrecognized protocol needs an upgrade or a re-link), so it must never read as
|
|
139
|
+
* `home_unreachable`, which GOAT's device-local fallback keys on. The status and Retry-After it
|
|
140
|
+
* carried before OCX-11 are kept: a lost bound socket can succeed on a fresh connection.
|
|
141
|
+
*/
|
|
142
|
+
export const LINK_RELAY_AUTH_FAILED_CODE = "link_relay_auth_failed";
|
|
143
|
+
|
|
144
|
+
function relayAuthenticationFailed(error: string): Response {
|
|
145
|
+
return Response.json({ error, code: LINK_RELAY_AUTH_FAILED_CODE }, {
|
|
146
|
+
status: 503,
|
|
147
|
+
headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Preserve path/query on a fixed IPv4 loopback origin; throw when the tunnel port is invalid. */
|
|
115
152
|
export function linkRelayDestination(url: URL, target: Pick<LinkRelayTarget, "tunnelPort">): string {
|
|
116
153
|
if (!isLinkPort(target.tunnelPort)) {
|
|
117
154
|
throw new RangeError("invalid link tunnel port");
|
|
@@ -156,6 +193,7 @@ function linkRequestHeaders(
|
|
|
156
193
|
return headers;
|
|
157
194
|
}
|
|
158
195
|
|
|
196
|
+
/** Drop hop-by-hop and stale length/encoding headers before exposing the decoded response. */
|
|
159
197
|
export function sanitizeLinkResponseHeaders(source: Headers): Headers {
|
|
160
198
|
const connectionNamed = new Set((source.get("connection") ?? "")
|
|
161
199
|
.split(",").map(value => value.trim().toLowerCase()).filter(Boolean));
|
|
@@ -304,6 +342,11 @@ function idleBoundedStream(
|
|
|
304
342
|
});
|
|
305
343
|
}
|
|
306
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Admit an allowlisted route and stream it through the authenticated tunnel with bounded bodies.
|
|
347
|
+
* Owner/auth preflight deadlines share one recovery attempt before data dispatch; an ambiguous
|
|
348
|
+
* post-dispatch failure never earns that recovery. Diagnostic failures cannot change admission.
|
|
349
|
+
*/
|
|
307
350
|
export async function relayLinkDataRequest(
|
|
308
351
|
req: Request,
|
|
309
352
|
target: LinkRelayTarget,
|
|
@@ -328,6 +371,16 @@ export async function relayLinkDataRequest(
|
|
|
328
371
|
return jsonError(431, "link relay request headers too large");
|
|
329
372
|
}
|
|
330
373
|
|
|
374
|
+
const requestId = randomUUID();
|
|
375
|
+
let admission: LinkAdmissionResult = { kind: "unavailable" };
|
|
376
|
+
let recoveryAttempts = 0;
|
|
377
|
+
const diagnostic = (event: LinkDiagnosticRecord["event"], outcome: LinkDiagnosticRecord["outcome"], elapsedMs?: number) => {
|
|
378
|
+
try { deps.diagnostic?.({ event, outcome, requestId,
|
|
379
|
+
checkId: admission.checkId, generation: admission.generation,
|
|
380
|
+
state: admission.state, elapsedMs: admission.elapsedMs,
|
|
381
|
+
...(elapsedMs !== undefined ? { elapsedMs } : {}), attempt: recoveryAttempts }); }
|
|
382
|
+
catch { /* diagnostics never alter admission */ }
|
|
383
|
+
};
|
|
331
384
|
const relayAbort = new AbortController();
|
|
332
385
|
const clock = deps.clock ?? defaultClock;
|
|
333
386
|
let headerTimer: ReturnType<typeof setTimeout> | undefined = clock.setTimeout(() => {
|
|
@@ -366,20 +419,45 @@ export async function relayLinkDataRequest(
|
|
|
366
419
|
const remaining = holdUntil - now;
|
|
367
420
|
return remaining > 0 && await gate.waitForConnected(remaining, relayAbort.signal);
|
|
368
421
|
};
|
|
369
|
-
const tunnelUnavailable = async (
|
|
422
|
+
const tunnelUnavailable = async (
|
|
423
|
+
reason: LinkDiagnosticRecord["outcome"] = admission.kind === "match" ? "unavailable" : admission.kind,
|
|
424
|
+
message = "link tunnel unavailable",
|
|
425
|
+
elapsedMs?: number,
|
|
426
|
+
): Promise<Response> => {
|
|
427
|
+
diagnostic("relay_failure", relayAbort.signal.aborted
|
|
428
|
+
? req.signal.aborted ? "client_abort" : "header_timeout" : reason, elapsedMs);
|
|
370
429
|
cleanup();
|
|
371
430
|
try { await body?.cancel(); } catch { /* best effort */ }
|
|
372
|
-
|
|
431
|
+
const response = reason === "auth_failed" ? relayAuthenticationFailed(message) : homeUnreachable(message);
|
|
432
|
+
response.headers.set("x-opencodex-link-request-id", requestId);
|
|
433
|
+
return response;
|
|
434
|
+
};
|
|
435
|
+
const retryPreflight = (reason: "deadline" | "auth_timeout", elapsedMs?: number): boolean => {
|
|
436
|
+
if (recoveryAttempts >= 1 || relayAbort.signal.aborted || bodyOverflow || bodyCancelled || body?.locked) return false;
|
|
437
|
+
recoveryAttempts += 1;
|
|
438
|
+
diagnostic("relay_retry", reason, elapsedMs);
|
|
439
|
+
return true;
|
|
440
|
+
};
|
|
441
|
+
const checkConnected = async (): Promise<boolean> => {
|
|
442
|
+
admission = tunnel ? await tunnel.connected({ requestId }) : { kind: "unavailable" };
|
|
443
|
+
return admission.kind === "match";
|
|
373
444
|
};
|
|
374
445
|
const readyForFetch = async (): Promise<boolean> => {
|
|
375
|
-
if (!tunnel) return false;
|
|
376
|
-
if (await
|
|
377
|
-
|
|
446
|
+
if (!tunnel || relayAbort.signal.aborted) return false;
|
|
447
|
+
if (await checkConnected()) return !relayAbort.signal.aborted;
|
|
448
|
+
// No fetch or body read has occurred. A deadline proves neither a foreign owner nor a
|
|
449
|
+
// broken SSH process: give this request one fresh proof, without caching or restarting it.
|
|
450
|
+
if (admission.kind === "deadline" && retryPreflight("deadline")) {
|
|
451
|
+
if (await checkConnected()) return !relayAbort.signal.aborted;
|
|
452
|
+
}
|
|
453
|
+
return !relayAbort.signal.aborted && tunnel.pending() && await holdForTunnel(tunnel)
|
|
454
|
+
&& await checkConnected() && !relayAbort.signal.aborted;
|
|
378
455
|
};
|
|
379
456
|
|
|
380
457
|
let upstream: Response | undefined;
|
|
381
458
|
while (!upstream) {
|
|
382
459
|
if (!await readyForFetch()) return await tunnelUnavailable();
|
|
460
|
+
const fetchStartedAt = performance.now();
|
|
383
461
|
try {
|
|
384
462
|
const init: RequestInit & { duplex?: "half" } = {
|
|
385
463
|
method: req.method,
|
|
@@ -391,24 +469,28 @@ export async function relayLinkDataRequest(
|
|
|
391
469
|
upstream = await (deps.fetchImpl ? deps.fetchImpl(destination, init) : fetchBoundLinkRelay(target, destination, init));
|
|
392
470
|
} catch (error) {
|
|
393
471
|
if (error instanceof LinkRelayAuthenticationError) {
|
|
394
|
-
|
|
395
|
-
try { await body?.cancel(); } catch { /* an upload might already be drained */ }
|
|
396
|
-
return jsonError(503, error.message, true);
|
|
472
|
+
return await tunnelUnavailable("auth_failed", error.message);
|
|
397
473
|
}
|
|
398
474
|
if (bodyOverflow) {
|
|
399
475
|
cleanup();
|
|
400
476
|
return jsonError(413, "link relay request body too large");
|
|
401
477
|
}
|
|
478
|
+
if (error instanceof LinkRelayAuthTimeoutError) {
|
|
479
|
+
// The transport certifies that only its credential-free challenge was sent. A new
|
|
480
|
+
// iteration obtains a fresh ownership proof and a new authenticated physical socket.
|
|
481
|
+
const elapsedMs = performance.now() - fetchStartedAt;
|
|
482
|
+
if (retryPreflight("auth_timeout", elapsedMs)) continue;
|
|
483
|
+
return await tunnelUnavailable("auth_timeout", "link tunnel unavailable", elapsedMs);
|
|
484
|
+
}
|
|
402
485
|
// A refused connection sent nothing, and an untouched body can go again once the tunnel
|
|
403
486
|
// is back inside the same hold window. Anything else is never replayed.
|
|
404
487
|
const resendable = connectionRefused(error) && (body === null || (!body.locked && !bodyCancelled));
|
|
405
|
-
if (!tunnel || !resendable || !tunnel.pending() || !await holdForTunnel(tunnel)) return await tunnelUnavailable();
|
|
488
|
+
if (!tunnel || !resendable || !tunnel.pending() || !await holdForTunnel(tunnel)) return await tunnelUnavailable("transport_error");
|
|
406
489
|
}
|
|
407
490
|
}
|
|
408
491
|
if (relayAbort.signal.aborted) {
|
|
409
|
-
cleanup();
|
|
410
492
|
try { await upstream.body?.cancel(); } catch { /* best effort */ }
|
|
411
|
-
return
|
|
493
|
+
return await tunnelUnavailable();
|
|
412
494
|
}
|
|
413
495
|
|
|
414
496
|
const sse = isSse(upstream.headers);
|
|
@@ -425,6 +507,7 @@ export async function relayLinkDataRequest(
|
|
|
425
507
|
try { await upstream.body?.cancel(); } catch { /* best effort */ }
|
|
426
508
|
return jsonError(502, "link relay response body too large");
|
|
427
509
|
}
|
|
510
|
+
if (recoveryAttempts > 0 && upstream.ok) diagnostic("relay_recovered", "recovered");
|
|
428
511
|
if (req.method === "HEAD" || !upstream.body) {
|
|
429
512
|
cleanup();
|
|
430
513
|
try { await upstream.body?.cancel(); } catch { /* best effort */ }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { chmodSync, mkdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
2
3
|
import { execFile } from "node:child_process";
|
|
3
4
|
import { readFile as readFileAsync } from "node:fs/promises";
|
|
@@ -13,17 +14,18 @@ import {
|
|
|
13
14
|
CLIENT_TUNNEL_RETRY_POLICY,
|
|
14
15
|
classifySshStderr,
|
|
15
16
|
dueForSpawn,
|
|
16
|
-
|
|
17
|
+
failedSpawnTunnel,
|
|
17
18
|
IDLE,
|
|
18
19
|
reduceTunnel,
|
|
19
20
|
type StderrClass,
|
|
20
21
|
type TunnelState,
|
|
21
22
|
} from "../link/tunnel-state";
|
|
22
23
|
import { isLinkPort } from "../link/ports";
|
|
23
|
-
import {
|
|
24
|
+
import { inspectIpv4LoopbackListener, type LoopbackOwnerLookupResult } from "../server/port-reclaim";
|
|
25
|
+
import type { LinkDiagnosticSink } from "../link/diagnostics";
|
|
24
26
|
import { isLinkConnection, readClientConnectionState } from "./state";
|
|
25
27
|
import { clientLinkStatePath, readClientLinkState, type ClientLinkState } from "./link-state";
|
|
26
|
-
import type { LinkTunnelGate } from "./link-relay";
|
|
28
|
+
import type { LinkTunnelGate, LinkAdmissionResult } from "./link-relay";
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* The client-owned `ssh -N -L 127.0.0.1:<tunnelPort>:127.0.0.1:<peerListenerPort> <alias>`
|
|
@@ -134,7 +136,8 @@ export interface ClientLinkSupervisorDeps extends ClientLinkTunnelDeps, OrphanRe
|
|
|
134
136
|
/** The link key for the keyed readiness probe. The runtime passes its cached key source. */
|
|
135
137
|
linkKey?: () => string | null;
|
|
136
138
|
/** Async local IPv4 LISTEN ownership; an unknown or foreign owner never receives the link key. */
|
|
137
|
-
|
|
139
|
+
inspectLoopbackListener?: (port: number, pid: number) => Promise<LoopbackOwnerLookupResult>;
|
|
140
|
+
diagnostic?: LinkDiagnosticSink;
|
|
138
141
|
fetchImpl?: typeof fetch;
|
|
139
142
|
now?: () => number;
|
|
140
143
|
random?: () => number;
|
|
@@ -563,6 +566,10 @@ async function probeTunnel(fetchImpl: typeof fetch, tunnelPort: number, key: str
|
|
|
563
566
|
}
|
|
564
567
|
}
|
|
565
568
|
|
|
569
|
+
/**
|
|
570
|
+
* Own the Child's SSH lifecycle and reconnect holds. Each connected() call checks fresh listener
|
|
571
|
+
* ownership and fences results against tunnel replacement or stop; diagnostics cannot admit it.
|
|
572
|
+
*/
|
|
566
573
|
export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}): ClientLinkSupervisor {
|
|
567
574
|
const sidecarPath = (): string => clientLinkStatePath(deps.configDir);
|
|
568
575
|
const readSidecar = deps.readSidecar ?? readWhenFileChanges(sidecarPath, () => readClientLinkState(sidecarPath()));
|
|
@@ -573,7 +580,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
573
580
|
const policy = CLIENT_TUNNEL_RETRY_POLICY;
|
|
574
581
|
const isAlive = deps.isAlive ?? defaultIsAlive;
|
|
575
582
|
const linkKey = deps.linkKey ?? (() => null);
|
|
576
|
-
const ownerLookup = deps.
|
|
583
|
+
const ownerLookup = deps.inspectLoopbackListener ?? inspectIpv4LoopbackListener;
|
|
577
584
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
578
585
|
const setSupervisorTimer = deps.setTimer ?? ((callback: () => void, ms: number) => {
|
|
579
586
|
const interval = setInterval(callback, ms);
|
|
@@ -620,9 +627,16 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
620
627
|
if (pid === null || pid === undefined || (adopted !== null && !adoptedRecord)) return null;
|
|
621
628
|
return { pid, generation: ownerGeneration };
|
|
622
629
|
};
|
|
623
|
-
const listenerOwnedByTunnel = async (port: number): Promise<
|
|
630
|
+
const listenerOwnedByTunnel = async (port: number, context?: { requestId?: string }): Promise<LinkAdmissionResult> => {
|
|
624
631
|
const candidate = ownerCandidate(port);
|
|
625
|
-
if (!candidate) return
|
|
632
|
+
if (!candidate) return { kind: "unavailable" };
|
|
633
|
+
const checkId = randomUUID();
|
|
634
|
+
const report = (kind: LinkAdmissionResult["kind"], elapsedMs = 0): LinkAdmissionResult => {
|
|
635
|
+
const check = { kind, elapsedMs, checkId, generation: candidate.generation, state: state.kind };
|
|
636
|
+
if (kind !== "match") try { deps.diagnostic?.({ event: "owner_lookup", outcome: kind, requestId: context?.requestId,
|
|
637
|
+
checkId, generation: candidate.generation, state: state.kind, elapsedMs }); } catch { /* best effort */ }
|
|
638
|
+
return check;
|
|
639
|
+
};
|
|
626
640
|
if (adopted !== null) {
|
|
627
641
|
const record = adoptedRecord;
|
|
628
642
|
const identity = record ? await inspectAdoptedIdentity(record, deps) : "unknown";
|
|
@@ -640,16 +654,28 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
640
654
|
invalidateOwnerProof();
|
|
641
655
|
setState(IDLE);
|
|
642
656
|
}
|
|
643
|
-
return
|
|
657
|
+
return report(identity === "mismatch" ? "identity_mismatch" : "identity_unknown");
|
|
644
658
|
}
|
|
645
659
|
}
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
660
|
+
const result = await Promise.resolve().then(() => ownerLookup(port, candidate.pid))
|
|
661
|
+
.catch((): LoopbackOwnerLookupResult => ({ kind: "lookup_error", elapsedMs: 0 }));
|
|
662
|
+
if (stopping || failure !== undefined || ownerGeneration !== candidate.generation || ownerCandidate(port)?.pid !== candidate.pid) {
|
|
663
|
+
return report("stale", result.elapsedMs);
|
|
664
|
+
}
|
|
665
|
+
return report(result.kind, result.elapsedMs);
|
|
666
|
+
};
|
|
667
|
+
const connected = async (context?: { requestId?: string }): Promise<LinkAdmissionResult> => {
|
|
668
|
+
if (!started || stopping || failure !== undefined || state.kind !== "connected" || linkId === null || tunnelPort === null) {
|
|
669
|
+
return { kind: "unavailable" };
|
|
670
|
+
}
|
|
671
|
+
const result = await listenerOwnedByTunnel(tunnelPort, context);
|
|
672
|
+
// State may change during the asynchronous lookup or an optional diagnostic callback.
|
|
673
|
+
if (result.kind === "match" && (!started || stopping || failure !== undefined
|
|
674
|
+
|| state.kind !== "connected" || result.generation !== ownerGeneration)) {
|
|
675
|
+
return { ...result, kind: "stale" };
|
|
676
|
+
}
|
|
677
|
+
return result;
|
|
649
678
|
};
|
|
650
|
-
const connected = async (): Promise<boolean> => started && !stopping && failure === undefined
|
|
651
|
-
&& state.kind === "connected" && linkId !== null && tunnelPort !== null
|
|
652
|
-
&& await listenerOwnedByTunnel(tunnelPort);
|
|
653
679
|
|
|
654
680
|
const settleWaiters = (): void => {
|
|
655
681
|
if (waiters.size === 0) return;
|
|
@@ -733,7 +759,8 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
733
759
|
peerListenerPort: sidecar.peerListenerPort,
|
|
734
760
|
}, deps);
|
|
735
761
|
} catch {
|
|
736
|
-
|
|
762
|
+
// A due retry that cannot start counts as a failed retry, so the backoff keeps its step.
|
|
763
|
+
setState(failedSpawnTunnel(state, "forward", timestamp, policy));
|
|
737
764
|
deps.warn?.("client link tunnel could not be started");
|
|
738
765
|
return;
|
|
739
766
|
}
|
|
@@ -800,7 +827,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
800
827
|
probeFlight = flight;
|
|
801
828
|
const wasConnected = state.kind === "connected";
|
|
802
829
|
void (async () => {
|
|
803
|
-
if (
|
|
830
|
+
if ((await listenerOwnedByTunnel(tunnelPort)).kind !== "match") return;
|
|
804
831
|
if (probeFlight !== flight || stopping || flight.abort.signal.aborted
|
|
805
832
|
|| child !== flight.child || adopted !== flight.adopted) return;
|
|
806
833
|
const key = linkKey();
|
|
@@ -808,7 +835,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
808
835
|
const result = await probeTunnel(fetchImpl, tunnelPort, key, flight.abort.signal);
|
|
809
836
|
// An aborted probe, or one whose listener changed while it ran, proves nothing.
|
|
810
837
|
if (probeFlight !== flight || stopping || child !== flight.child || adopted !== flight.adopted
|
|
811
|
-
||
|
|
838
|
+
|| (await listenerOwnedByTunnel(tunnelPort)).kind !== "match") return;
|
|
812
839
|
applyProbe(result, wasConnected);
|
|
813
840
|
})().catch(() => undefined).finally(() => {
|
|
814
841
|
if (probeFlight === flight) probeFlight = undefined;
|
|
@@ -971,7 +998,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
971
998
|
pending,
|
|
972
999
|
connected,
|
|
973
1000
|
waitForConnected(timeoutMs: number, signal?: AbortSignal): Promise<boolean> {
|
|
974
|
-
if (state.kind === "connected") return connected();
|
|
1001
|
+
if (state.kind === "connected") return connected().then(result => result.kind === "match");
|
|
975
1002
|
if (!pending() || waiters.size >= CLIENT_LINK_MAX_HOLDS || signal?.aborted || !(timeoutMs > 0)) {
|
|
976
1003
|
return Promise.resolve(false);
|
|
977
1004
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recordLinkDiagnostic } from "../link/diagnostics";
|
|
1
2
|
import type { Server } from "bun";
|
|
2
3
|
import { loadConfig } from "../config";
|
|
3
4
|
import { browserSecurityHeaders, requestPolicyView } from "../server/auth-cors";
|
|
@@ -21,10 +22,25 @@ import type { LinkTunnelGate } from "./link-relay";
|
|
|
21
22
|
import { readClientLinkState, type ClientLinkState } from "./link-state";
|
|
22
23
|
import { projectClientLinkChild, type ClientLinkSidecarRead } from "./link-status";
|
|
23
24
|
import type { ClientLinkSupervisorStatus } from "./link-tunnel";
|
|
25
|
+
import type { LinkReadiness } from "./link-readiness";
|
|
24
26
|
import { packageVersion } from "../lib/package-version";
|
|
27
|
+
import { createLocalAttestationProof, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../lib/local-management-attestation";
|
|
28
|
+
import {
|
|
29
|
+
CLIENT_RESTART_BUDGET_MS,
|
|
30
|
+
parseExpectedSystemRestartPid,
|
|
31
|
+
RESTART_SUPERVISED_ERROR,
|
|
32
|
+
SYSTEM_RESTART_CAPABILITY_HEADER,
|
|
33
|
+
SYSTEM_RESTART_CAPABILITY_VERSION,
|
|
34
|
+
SYSTEM_RESTART_EXPECTED_PID_HEADER,
|
|
35
|
+
SYSTEM_RESTART_METHOD,
|
|
36
|
+
SYSTEM_RESTART_NONCE_HEADER,
|
|
37
|
+
SYSTEM_RESTART_PATH,
|
|
38
|
+
verifySystemRestartCapability,
|
|
39
|
+
} from "../lib/system-restart-contract";
|
|
25
40
|
import { linkRouteAllowed } from "../link/routes";
|
|
26
41
|
import { linkHomeName, linkHomePhrase } from "./link-home";
|
|
27
42
|
import { relayLinkManagementRead } from "./link-management-relay";
|
|
43
|
+
import type { ClientRestartAdmission } from "./runtime-restart";
|
|
28
44
|
|
|
29
45
|
const VERSION = packageVersion("0.0.0");
|
|
30
46
|
const GUI_SPA_PATHS = new Set([
|
|
@@ -51,6 +67,22 @@ export interface MachineListenerDeps {
|
|
|
51
67
|
linkTunnel?: LinkTunnelGate;
|
|
52
68
|
/** Link mode: relay seams (deadline clock and byte cap). */
|
|
53
69
|
linkRelay?: LinkIngress["relay"];
|
|
70
|
+
/**
|
|
71
|
+
* Link mode: whether relayed requests can be served now. `GET /readyz` answers 503 `pending`
|
|
72
|
+
* with `link.reason` while it is not; `/healthz` is unaffected (`link-readiness.ts`).
|
|
73
|
+
*/
|
|
74
|
+
linkReadiness?: () => LinkReadiness | Promise<LinkReadiness>;
|
|
75
|
+
/**
|
|
76
|
+
* The bound restart (OCX-6). With it, `/healthz` proves this process's identity to a challenge,
|
|
77
|
+
* advertises `restartCapability` and `startedAt`, and `POST /api/system/restart` accepts a
|
|
78
|
+
* capability signed with `attestationSecret` for this pid and port, and nothing else.
|
|
79
|
+
*/
|
|
80
|
+
restart?: {
|
|
81
|
+
attestationSecret: string;
|
|
82
|
+
/** ISO start time of this runtime's process. */
|
|
83
|
+
startedAt: string;
|
|
84
|
+
request: () => Promise<ClientRestartAdmission>;
|
|
85
|
+
};
|
|
54
86
|
serve?: (options: Parameters<typeof Bun.serve>[0]) => Server<unknown>;
|
|
55
87
|
}
|
|
56
88
|
|
|
@@ -68,6 +100,8 @@ export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolea
|
|
|
68
100
|
if (linkMode && linkRouteAllowed(url, req)) return true;
|
|
69
101
|
const path = url.pathname;
|
|
70
102
|
if (req.method === "GET" && (path === "/healthz" || path === "/readyz" || path === "/" || path === "/opencodex-session")) return true;
|
|
103
|
+
// The bound restart. Only a capability signed for this exact process admits it (see the handler).
|
|
104
|
+
if (req.method === SYSTEM_RESTART_METHOD && path === SYSTEM_RESTART_PATH) return true;
|
|
71
105
|
if ((req.method === "GET" || req.method === "HEAD") && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true;
|
|
72
106
|
// The one link route a connected Child serves: its own read-only link status.
|
|
73
107
|
if (linkMode && (req.method === "GET" || req.method === "HEAD") && path === "/api/link/status") return true;
|
|
@@ -125,7 +159,7 @@ export function startMachineListener(
|
|
|
125
159
|
apiKeyId: connection.apiKeyId,
|
|
126
160
|
policy: requestPolicyView(config, "127.0.0.1"),
|
|
127
161
|
linkKey: deps.linkKeySource ?? createLinkKeySource(connection.tokenFingerprint, deps.linkKey),
|
|
128
|
-
relay: { fetchImpl: deps.fetchImpl, bodyLimitBytes: inboundBodyLimit, tunnel: deps.linkTunnel, ...deps.linkRelay },
|
|
162
|
+
relay: { diagnostic: recordLinkDiagnostic, fetchImpl: deps.fetchImpl, bodyLimitBytes: inboundBodyLimit, tunnel: deps.linkTunnel, ...deps.linkRelay },
|
|
129
163
|
}
|
|
130
164
|
: null;
|
|
131
165
|
return (deps.serve ?? (options => Bun.serve(options)))({
|
|
@@ -143,10 +177,34 @@ export function startMachineListener(
|
|
|
143
177
|
}
|
|
144
178
|
if (!machineRouteAllowed(url, req, relayEnabled, linkMode)) return json404(req);
|
|
145
179
|
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
146
|
-
|
|
180
|
+
const restart = deps.restart;
|
|
181
|
+
const response = Response.json({
|
|
182
|
+
service: "opencodex", version: VERSION, role: "client", uptime: process.uptime(), pid: process.pid, port: server.port,
|
|
183
|
+
...(restart ? { startedAt: restart.startedAt, restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION, restartBudgetMs: CLIENT_RESTART_BUDGET_MS } : {}),
|
|
184
|
+
});
|
|
185
|
+
const challenge = restart ? req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER) : null;
|
|
186
|
+
const proof = challenge && server.port
|
|
187
|
+
? createLocalAttestationProof(restart!.attestationSecret, challenge, process.pid, server.port)
|
|
188
|
+
: null;
|
|
189
|
+
if (proof) response.headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof);
|
|
190
|
+
return response;
|
|
191
|
+
}
|
|
192
|
+
if (url.pathname === SYSTEM_RESTART_PATH && req.method === SYSTEM_RESTART_METHOD) {
|
|
193
|
+
return await handleBoundRestart(req, server, deps.restart);
|
|
147
194
|
}
|
|
148
195
|
if (url.pathname === "/readyz" && req.method === "GET") {
|
|
149
|
-
|
|
196
|
+
const identity = { service: "opencodex", version: VERSION, role: "client" } as const;
|
|
197
|
+
const facts = { uptime: process.uptime(), pid: process.pid, port: server.port, protocolVersion: 1 };
|
|
198
|
+
let link: LinkReadiness | null = null;
|
|
199
|
+
if (linkMode && deps.linkReadiness) {
|
|
200
|
+
try { link = await deps.linkReadiness(); } catch { link = { ready: false, reason: "link_unverified" }; }
|
|
201
|
+
}
|
|
202
|
+
if (link && !link.ready) {
|
|
203
|
+
// The /readyz contract: not ready is 503 with Retry-After and status pending or failed.
|
|
204
|
+
return Response.json({ ...identity, status: "pending", ...facts, link: { state: "unavailable", reason: link.reason } },
|
|
205
|
+
{ status: 503, headers: { "Retry-After": "1", "Cache-Control": "no-store" } });
|
|
206
|
+
}
|
|
207
|
+
return Response.json({ ...identity, status: "ready", ...facts, ...(link ? { link: { state: "available" } } : {}) });
|
|
150
208
|
}
|
|
151
209
|
if (url.pathname.startsWith("/api/machine/hub-relay/")) {
|
|
152
210
|
if (!relayEnabled) return json404(req);
|
|
@@ -232,3 +290,45 @@ export function startMachineListener(
|
|
|
232
290
|
},
|
|
233
291
|
} as Parameters<typeof Bun.serve>[0]);
|
|
234
292
|
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* `POST /api/system/restart` on the client listener (OCX-6). The only accepted principal is the
|
|
296
|
+
* process-bound restart capability `ocx restart` derives from this runtime's attestation secret:
|
|
297
|
+
* no dashboard session, admin token or link key reaches it, and a browser cannot mint it.
|
|
298
|
+
*/
|
|
299
|
+
async function handleBoundRestart(
|
|
300
|
+
req: Request,
|
|
301
|
+
server: Server<unknown>,
|
|
302
|
+
restart: MachineListenerDeps["restart"],
|
|
303
|
+
): Promise<Response> {
|
|
304
|
+
const noStore = { "cache-control": "no-store" };
|
|
305
|
+
if (!restart || !server.port) return json404(req);
|
|
306
|
+
const expected = parseExpectedSystemRestartPid(req.headers.get(SYSTEM_RESTART_EXPECTED_PID_HEADER));
|
|
307
|
+
if (expected.kind !== "present" || expected.pid !== process.pid) {
|
|
308
|
+
return Response.json({ success: false, error: "Restart target identity changed." }, { status: 409, headers: noStore });
|
|
309
|
+
}
|
|
310
|
+
const authorized = verifySystemRestartCapability(
|
|
311
|
+
restart.attestationSecret,
|
|
312
|
+
req.headers.get(SYSTEM_RESTART_NONCE_HEADER),
|
|
313
|
+
SYSTEM_RESTART_METHOD,
|
|
314
|
+
SYSTEM_RESTART_PATH,
|
|
315
|
+
process.pid,
|
|
316
|
+
server.port,
|
|
317
|
+
req.headers.get(SYSTEM_RESTART_CAPABILITY_HEADER),
|
|
318
|
+
);
|
|
319
|
+
if (!authorized) return Response.json({ success: false, error: "restart capability required" }, { status: 401, headers: noStore });
|
|
320
|
+
const admission = await restart.request();
|
|
321
|
+
if (admission.kind === "supervised") {
|
|
322
|
+
return Response.json({
|
|
323
|
+
success: false,
|
|
324
|
+
error: { code: RESTART_SUPERVISED_ERROR, supervisor: admission.supervisor },
|
|
325
|
+
}, { status: 409, headers: noStore });
|
|
326
|
+
}
|
|
327
|
+
return Response.json({
|
|
328
|
+
success: true,
|
|
329
|
+
message: admission.alreadyRestarting ? "Restart already in progress." : "Restarting the client runtime.",
|
|
330
|
+
activeTurnCount: 0,
|
|
331
|
+
drainTimeoutMs: CLIENT_RESTART_BUDGET_MS,
|
|
332
|
+
alreadyDraining: admission.alreadyRestarting,
|
|
333
|
+
}, { status: 202, headers: noStore });
|
|
334
|
+
}
|