@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927
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 +3 -0
- package/gui/dist/assets/App-B5-cAEd2.js +51 -0
- package/gui/dist/assets/App-DCBismRi.css +1 -0
- package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
- package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
- package/gui/dist/assets/index-BcXblvem.js +86 -0
- package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
- package/gui/dist/index.html +21 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-guard.ts +13 -1
- package/src/adapters/anthropic-image-normalize.ts +3 -2
- package/src/adapters/anthropic-output-schema.ts +36 -0
- package/src/adapters/anthropic.ts +10 -2
- package/src/adapters/base.ts +7 -0
- package/src/adapters/codebuddy/live-models.ts +180 -0
- package/src/adapters/coding-agent/protocol.ts +91 -12
- package/src/adapters/coding-agent/turn.ts +32 -27
- package/src/adapters/devin/cloud-direct/index.ts +1 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
- package/src/adapters/devin.ts +9 -6
- package/src/adapters/google-http.ts +4 -4
- package/src/adapters/google.ts +93 -3
- package/src/adapters/kiro/adapter.ts +6 -2
- package/src/adapters/kiro/stream.ts +12 -2
- package/src/adapters/kiro/usage.ts +3 -1
- package/src/adapters/kiro-errors.ts +11 -1
- package/src/adapters/kiro-events.ts +27 -1
- package/src/adapters/kiro-refusal.ts +30 -0
- package/src/adapters/kiro-retry.ts +71 -31
- package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
- package/src/adapters/openai-chat/messages.ts +31 -4
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
- package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
- package/src/adapters/openai-chat/tool-schema.ts +6 -2
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -3
- package/src/adapters/opencode-go-additional-tools.ts +19 -10
- package/src/adapters/physical-send.ts +10 -3
- package/src/adapters/registry.ts +2 -1
- package/src/adapters/responses-tool-schema.ts +31 -3
- package/src/adapters/run-turn-queue.ts +63 -23
- package/src/adapters/unique-tool-call-ids.ts +63 -0
- package/src/adapters/xai-web-search.ts +32 -2
- package/src/bridge/sse.ts +14 -2
- package/src/chat/inbound.ts +20 -6
- package/src/claude/agents-inject.ts +4 -0
- package/src/claude/desktop-3p.ts +53 -13
- package/src/claude/desktop-profile.ts +41 -4
- package/src/claude/inbound-model-options.ts +14 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +17 -1
- package/src/claude/intercept/local-ca.ts +7 -1
- package/src/claude/message-threads.ts +28 -0
- package/src/cli/account-api.ts +13 -0
- package/src/cli/account-auth.ts +49 -2
- package/src/cli/account-extended.ts +4 -2
- package/src/cli/account.ts +4 -2
- package/src/cli/capabilities.ts +17 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/dispatch.ts +62 -5
- package/src/cli/index.ts +84 -79
- package/src/cli/minimax.ts +4 -2
- package/src/cli/opencode.ts +6 -1
- package/src/cli/restart-handoff.ts +110 -0
- package/src/cli/status.ts +51 -0
- package/src/client/connect.ts +34 -15
- package/src/client/link-ingress.ts +102 -0
- package/src/client/link-join.ts +37 -16
- package/src/client/link-relay.ts +228 -45
- package/src/client/link-state.ts +54 -0
- package/src/client/link-status.ts +39 -0
- package/src/client/link-teardown.ts +2 -2
- package/src/client/link-tunnel.ts +615 -86
- package/src/client/machine-api.ts +10 -4
- package/src/client/machine-listener.ts +57 -10
- package/src/client/runtime.ts +210 -35
- package/src/clients/config-export/constants.ts +1 -12
- package/src/clients/config-export/contracts.ts +2 -0
- package/src/clients/config-export/model-metadata.ts +9 -3
- package/src/clients/config-export/omp.ts +1 -1
- package/src/clients/config-export/zcode-store.ts +2 -2
- package/src/clients/config-export.ts +36 -3
- package/src/codex/app-server-processes.ts +32 -0
- package/src/codex/app-server-restart-service.ts +20 -3
- package/src/codex/catalog/access-programs.ts +50 -0
- package/src/codex/catalog/build-entries.ts +13 -0
- package/src/codex/catalog/derive-entry.ts +1 -0
- package/src/codex/catalog/model-hints.ts +11 -0
- package/src/codex/catalog/parsing.ts +6 -2
- package/src/codex/catalog/provider-models.ts +81 -0
- package/src/codex/catalog/retained-sync.ts +3 -0
- package/src/codex/codex-write-lock.ts +2 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desired-state.ts +14 -4
- package/src/codex/home.ts +21 -3
- package/src/codex/inject/restore.ts +17 -0
- package/src/codex/inject/routing-classify.ts +3 -2
- package/src/codex/inject-coordination.ts +4 -1
- package/src/codex/inject.ts +8 -1
- package/src/codex/journal.ts +6 -2
- package/src/codex/management-convergence.ts +9 -0
- package/src/codex/model-entitlements.ts +28 -5
- package/src/codex/routing/idle-window.ts +58 -0
- package/src/codex/routing-drift.ts +134 -0
- package/src/codex/routing-healer.ts +419 -0
- package/src/codex/routing.ts +10 -0
- package/src/codex/runtime.ts +188 -57
- package/src/codex/sibling-handoff.ts +71 -0
- package/src/codex/sibling-start.ts +139 -0
- package/src/codex/sync.ts +5 -3
- package/src/combos/failover.ts +32 -5
- package/src/combos/request.ts +11 -3
- package/src/combos/reset-window.ts +10 -5
- package/src/combos/resolve.ts +36 -6
- package/src/config/diagnostics.ts +2 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/paths.ts +7 -1
- package/src/config/process-state.ts +10 -1
- package/src/config/schema/compaction-recovery.ts +15 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +9 -3
- package/src/generated/compatibility-version.json +360 -204
- package/src/images/loop.ts +47 -32
- package/src/integrations/catalog-refresh.ts +4 -0
- package/src/integrations/omp-yaml-source.ts +1 -0
- package/src/lib/claude-request-projection.ts +101 -0
- package/src/lib/local-account-switch-capability.ts +48 -0
- package/src/lib/local-upstream.ts +146 -0
- package/src/lib/package-tree-integrity.ts +25 -3
- package/src/lib/package-tree-retarget.ts +130 -0
- package/src/lib/process-control.ts +4 -1
- package/src/lib/system-restart-contract.ts +106 -0
- package/src/lib/upstream-retry.ts +57 -1
- package/src/link/ports.ts +9 -0
- package/src/link/ssh-argv.ts +16 -0
- package/src/link/ssh-runner.ts +70 -2
- package/src/link/tunnel-state.ts +58 -9
- package/src/oauth/account-quota-rank.ts +29 -18
- package/src/oauth/generic-account-failover.ts +179 -22
- package/src/oauth/index.ts +17 -4
- package/src/oauth/kiro-account-load.ts +107 -0
- package/src/oauth/kiro-device-login.ts +312 -0
- package/src/oauth/kiro-terminal-failover.ts +28 -0
- package/src/oauth/login-flow-state.ts +7 -1
- package/src/oauth/pool-settings-capability.ts +23 -14
- package/src/oauth/store.ts +56 -3
- package/src/oauth/types.ts +4 -0
- package/src/plugins/loader.ts +350 -0
- package/src/plugins/upstream-hooks.ts +119 -0
- package/src/protocols/encoders/adapter-events.ts +9 -2
- package/src/providers/account-quota-disk.ts +42 -4
- package/src/providers/codebuddy-models.ts +6 -2
- package/src/providers/command-code-efforts.ts +33 -10
- package/src/providers/kiro-account-state-disk.ts +44 -0
- package/src/providers/kiro-model-catalog.ts +162 -0
- package/src/providers/kiro-models.ts +5 -4
- package/src/providers/kiro-quota-metrics.ts +35 -0
- package/src/providers/kiro-usage.ts +118 -14
- package/src/providers/quota/account-cache.ts +43 -7
- package/src/providers/quota/antigravity.ts +8 -3
- package/src/providers/quota/kiro-account-probe.ts +12 -0
- package/src/providers/quota/vendor-probes-key.ts +31 -26
- package/src/providers/quota/vendor-probes-oauth.ts +16 -7
- package/src/providers/quota-types.ts +14 -0
- package/src/providers/quota.ts +21 -21
- package/src/providers/registry/entries-extended.ts +5 -3
- package/src/providers/request-pacing.ts +410 -12
- package/src/remote-control/workspace-codex-runtime.ts +4 -3
- package/src/responses/citation-markers.ts +132 -65
- package/src/responses/hosted-tool-policy.ts +14 -3
- package/src/responses/parser-content.ts +3 -2
- package/src/responses/parser.ts +4 -1
- package/src/responses/schema.ts +4 -0
- package/src/responses/visualization-directives.ts +182 -0
- package/src/server/chat-native-sse.ts +24 -12
- package/src/server/chat-native.ts +137 -68
- package/src/server/claude-messages.ts +147 -12
- package/src/server/index/link-listener.ts +7 -0
- package/src/server/index/optional-listeners.ts +4 -1
- package/src/server/index/package-tree-guard.ts +35 -16
- package/src/server/index/serve-options.ts +4 -2
- package/src/server/index/startup-warnings.ts +6 -0
- package/src/server/index.ts +7 -7
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/inference/context.ts +30 -2
- package/src/server/local-account-switch-auth.ts +79 -0
- package/src/server/management/agent-settings-routes.ts +11 -3
- package/src/server/management/config-routes.ts +28 -11
- package/src/server/management/context.ts +2 -0
- package/src/server/management/link-routes.ts +123 -39
- package/src/server/management/logs-usage-routes.ts +9 -42
- package/src/server/management/model-rows.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +67 -12
- package/src/server/management/route-registry.ts +5 -5
- package/src/server/management/sibling-guard.ts +60 -0
- package/src/server/management/storage-log-guard-routes.ts +72 -6
- package/src/server/management/system-restart.ts +92 -135
- package/src/server/management/system-routes.ts +1 -1
- package/src/server/management-api.ts +31 -3
- package/src/server/management-auth.ts +3 -0
- package/src/server/port-reclaim.ts +111 -1
- package/src/server/proxy-liveness.ts +16 -0
- package/src/server/relay.ts +16 -15
- package/src/server/request-log-filter.ts +28 -27
- package/src/server/request-log.ts +7 -3
- package/src/server/request-metrics.ts +28 -0
- package/src/server/responses/adapter-continuation.ts +151 -21
- package/src/server/responses/adapter-delivery.ts +21 -7
- package/src/server/responses/adapter-dispatch.ts +239 -51
- package/src/server/responses/codex-ws-pool.ts +5 -4
- package/src/server/responses/codex-ws-request.ts +4 -1
- package/src/server/responses/compact.ts +8 -4
- package/src/server/responses/compaction-recovery-policy.ts +116 -0
- package/src/server/responses/compaction-recovery.ts +330 -0
- package/src/server/responses/core-combo-failure.ts +1 -0
- package/src/server/responses/core-combo.ts +5 -1
- package/src/server/responses/core-lifetime.ts +22 -0
- package/src/server/responses/core-options.ts +16 -1
- package/src/server/responses/core.ts +14 -4
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +82 -31
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +12 -2
- package/src/server/responses/passthrough-execution.ts +2 -2
- package/src/server/responses/request-send-budget.ts +21 -5
- package/src/server/responses/request-transport.ts +116 -18
- package/src/server/responses/reset-replay.ts +32 -0
- package/src/server/responses/run-turn-execution.ts +136 -19
- package/src/server/responses/sidecar-execution.ts +41 -8
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses/ws-upstream.ts +44 -4
- package/src/server/restart-replacement.ts +382 -0
- package/src/server/startup-health-cache.ts +23 -4
- package/src/server/stop-teardown.ts +35 -1
- package/src/server/system-env.ts +3 -0
- package/src/service/windows-taskxml.ts +11 -7
- package/src/service/windows-wrapper-exit.ts +8 -0
- package/src/stall-timeout.ts +35 -10
- package/src/storage/policy-job.ts +4 -0
- package/src/storage/scanner.ts +177 -29
- package/src/storage/storage-mutation-coordinator.ts +17 -0
- package/src/tray/windows-tray.ps1 +50 -23
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +5 -1
- package/src/types/request.ts +13 -2
- package/src/update/index.ts +3 -1
- package/src/update/job.ts +6 -2
- package/src/update/mise-launcher-target.ts +100 -0
- package/src/update/worker-launch.ts +46 -0
- package/src/usage/log.ts +2 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-BJsT8Icc.css +0 -1
- package/gui/dist/assets/App-D3pNGiN4.js +0 -51
- package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
- package/gui/dist/assets/index-CouvvtMV.js +0 -86
- package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
package/src/client/link-relay.ts
CHANGED
|
@@ -2,17 +2,19 @@ import {
|
|
|
2
2
|
boundedRelayResponseStream,
|
|
3
3
|
filterRelayHeaders,
|
|
4
4
|
headersWithinLimit,
|
|
5
|
-
HUB_RELAY_DEFAULT_TIMEOUT_MS,
|
|
6
|
-
HUB_RELAY_REQUEST_BODY_MAX_BYTES,
|
|
7
|
-
HUB_RELAY_RESPONSE_BODY_MAX_BYTES,
|
|
8
|
-
readBoundedRelayRequestBody,
|
|
9
5
|
validateHubRelayRequestHeaders,
|
|
10
6
|
} from "./hub-relay";
|
|
11
7
|
import { linkRouteAllowed } from "../link/routes";
|
|
12
8
|
import { isLinkPort } from "../link/ports";
|
|
9
|
+
import { resolveInboundBodyLimitBytes } from "../server/request-decompress";
|
|
13
10
|
|
|
14
11
|
export interface LinkRelayTarget {
|
|
15
12
|
tunnelPort: number;
|
|
13
|
+
/**
|
|
14
|
+
* The stored link key. The relay sends it in place of every caller credential, so the Home
|
|
15
|
+
* admits the request as this link and serves it with its own accounts.
|
|
16
|
+
*/
|
|
17
|
+
admissionKey: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface LinkRelayClock {
|
|
@@ -20,15 +22,66 @@ export interface LinkRelayClock {
|
|
|
20
22
|
clearTimeout: typeof clearTimeout;
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The Child's tunnel as the relay sees it (the client link supervisor implements it). A request
|
|
27
|
+
* waits on it only while the tunnel is being (re)established. Every relayed fetch waits for a
|
|
28
|
+
* fresh bounded asynchronous ownership proof, including after a reconnect or refused retry.
|
|
29
|
+
*/
|
|
30
|
+
export interface LinkTunnelGate {
|
|
31
|
+
/** True only while a keyed probe has established this live tunnel. */
|
|
32
|
+
connected(): boolean | Promise<boolean>;
|
|
33
|
+
/** True while the tunnel is connecting or reconnecting. */
|
|
34
|
+
pending(): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Resolves true once the tunnel is connected, and false after `timeoutMs`, when `signal` aborts,
|
|
37
|
+
* when the tunnel stops being re-established (failed or stopped), or when too many requests wait.
|
|
38
|
+
*/
|
|
39
|
+
waitForConnected(timeoutMs: number, signal?: AbortSignal): Promise<boolean>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The gate for a Home-initiated link. The Home runs `ssh -R` and owns the forward, so the Child
|
|
44
|
+
* has no tunnel supervisor and no local SSH process whose socket it could prove. This gate keeps
|
|
45
|
+
* the 2.67.0 behaviour for that link only: forward to the tunnel port without an ownership proof,
|
|
46
|
+
* never hold, and answer 503 when the connection is refused. Child-initiated links keep their
|
|
47
|
+
* supervisor, and a relay with no gate at all still refuses every request.
|
|
48
|
+
*/
|
|
49
|
+
export const HOME_INITIATED_LINK_TUNNEL: LinkTunnelGate = {
|
|
50
|
+
connected: () => true,
|
|
51
|
+
pending: () => false,
|
|
52
|
+
waitForConnected: async () => false,
|
|
53
|
+
};
|
|
54
|
+
|
|
23
55
|
export interface LinkRelayDeps {
|
|
24
56
|
fetchImpl?: typeof fetch;
|
|
25
57
|
clock?: LinkRelayClock;
|
|
26
|
-
|
|
58
|
+
/** Time allowed for the Home's response headers; a caller abort still ends the wait sooner. */
|
|
59
|
+
headerTimeoutMs?: number;
|
|
27
60
|
sseIdleTimeoutMs?: number;
|
|
61
|
+
/** Byte cap for the streamed request body and for a non-SSE response body. */
|
|
62
|
+
bodyLimitBytes?: number;
|
|
63
|
+
/** The Child's tunnel; without positive connected proof a request is refused. */
|
|
64
|
+
tunnel?: LinkTunnelGate;
|
|
65
|
+
/** The longest a request waits for a reconnecting tunnel, from its first wait. */
|
|
66
|
+
holdMs?: number;
|
|
67
|
+
now?: () => number;
|
|
28
68
|
}
|
|
29
69
|
|
|
30
70
|
export const LINK_RELAY_RETRY_AFTER_SECONDS = 1;
|
|
31
71
|
export const LINK_RELAY_SSE_IDLE_TIMEOUT_MS = 300_000;
|
|
72
|
+
/**
|
|
73
|
+
* The Home may hold a turn (remote compaction, a slow first byte) far past the management
|
|
74
|
+
* relay's 15 s, so the data plane waits for response headers as long as its SSE idle limit.
|
|
75
|
+
*/
|
|
76
|
+
export const LINK_RELAY_HEADER_TIMEOUT_MS = 300_000;
|
|
77
|
+
/** The data-plane default: the same inbound limit a standalone listener admits. */
|
|
78
|
+
export const LINK_RELAY_BODY_MAX_BYTES = resolveInboundBodyLimitBytes(undefined);
|
|
79
|
+
/**
|
|
80
|
+
* How long a request waits for a tunnel that is connecting or reconnecting (right after the
|
|
81
|
+
* restart into a Child, after sleep, after an ssh exit) before it is answered 503. Codex's own
|
|
82
|
+
* retries cover only a few seconds.
|
|
83
|
+
*/
|
|
84
|
+
export const LINK_RELAY_HOLD_MS = 15_000;
|
|
32
85
|
|
|
33
86
|
const defaultClock: LinkRelayClock = {
|
|
34
87
|
setTimeout: globalThis.setTimeout,
|
|
@@ -36,24 +89,59 @@ const defaultClock: LinkRelayClock = {
|
|
|
36
89
|
};
|
|
37
90
|
const REQUEST_OMITTED_HEADERS = new Set(["content-length", "host"]);
|
|
38
91
|
const RESPONSE_OMITTED_HEADERS = new Set(["content-encoding", "content-length"]);
|
|
92
|
+
/**
|
|
93
|
+
* Caller credentials never cross the tunnel. The Child's own ChatGPT or Anthropic credential
|
|
94
|
+
* stays on the Child, and the Home sees exactly one admission: the link key.
|
|
95
|
+
*/
|
|
96
|
+
const CALLER_CREDENTIAL_HEADERS = ["authorization", "x-api-key", "x-opencodex-api-key", "chatgpt-account-id", "cookie"] as const;
|
|
39
97
|
|
|
40
98
|
function jsonError(status: number, error: string, retry = false): Response {
|
|
41
99
|
const headers = retry ? { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) } : undefined;
|
|
42
100
|
return Response.json({ error }, { status, headers });
|
|
43
101
|
}
|
|
44
102
|
|
|
45
|
-
export function linkRelayDestination(url: URL, target: LinkRelayTarget): string {
|
|
103
|
+
export function linkRelayDestination(url: URL, target: Pick<LinkRelayTarget, "tunnelPort">): string {
|
|
46
104
|
if (!isLinkPort(target.tunnelPort)) {
|
|
47
105
|
throw new RangeError("invalid link tunnel port");
|
|
48
106
|
}
|
|
49
107
|
return `http://127.0.0.1:${target.tunnelPort}${url.pathname}${url.search}`;
|
|
50
108
|
}
|
|
51
109
|
|
|
52
|
-
|
|
53
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The caller's headers for the framing check. The listener's HTTP parser has already de-chunked
|
|
112
|
+
* the body, and the relay re-frames it from the stream, so a lone `Transfer-Encoding: chunked`
|
|
113
|
+
* with no Content-Length is admitted, as a standalone admits it. Any other Transfer-Encoding, or
|
|
114
|
+
* one next to a Content-Length, stays in the list and is refused as ambiguous framing.
|
|
115
|
+
*/
|
|
116
|
+
function linkFramingHeaders(source: Headers): Array<[string, string]> {
|
|
117
|
+
const raw = [...source];
|
|
118
|
+
const transferEncoding = source.get("transfer-encoding");
|
|
119
|
+
if (transferEncoding === null || transferEncoding.trim().toLowerCase() !== "chunked" || source.has("content-length")) return raw;
|
|
120
|
+
return raw.filter(([name]) => name.toLowerCase() !== "transfer-encoding");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The headers sent to the Home: hop-by-hop, Connection-nominated and caller credential headers
|
|
125
|
+
* dropped, then the link key attached. `GET /v1/usage` admits only the dedicated header; every
|
|
126
|
+
* other link route takes the key as a Bearer, the same wire an `env_key` Codex config sent.
|
|
127
|
+
*/
|
|
128
|
+
export function forwardLinkRequestHeaders(source: Headers, admissionKey: string, pathname: string): Headers {
|
|
129
|
+
const validation = validateHubRelayRequestHeaders(linkFramingHeaders(source));
|
|
54
130
|
if (!validation.ok) return new Headers();
|
|
55
|
-
|
|
56
|
-
|
|
131
|
+
return linkRequestHeaders(source, admissionKey, pathname, validation.connectionNamed);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function linkRequestHeaders(
|
|
135
|
+
source: Headers,
|
|
136
|
+
admissionKey: string,
|
|
137
|
+
pathname: string,
|
|
138
|
+
connectionNamed: ReadonlySet<string>,
|
|
139
|
+
): Headers {
|
|
140
|
+
const omitted = new Set<string>([...REQUEST_OMITTED_HEADERS, ...CALLER_CREDENTIAL_HEADERS, ...connectionNamed]);
|
|
141
|
+
const headers = filterRelayHeaders(source, undefined, omitted);
|
|
142
|
+
if (pathname === "/v1/usage") headers.set("x-opencodex-api-key", admissionKey);
|
|
143
|
+
else headers.set("authorization", `Bearer ${admissionKey}`);
|
|
144
|
+
return headers;
|
|
57
145
|
}
|
|
58
146
|
|
|
59
147
|
export function sanitizeLinkResponseHeaders(source: Headers): Headers {
|
|
@@ -66,6 +154,60 @@ function isSse(headers: Headers): boolean {
|
|
|
66
154
|
return headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream";
|
|
67
155
|
}
|
|
68
156
|
|
|
157
|
+
function positive(value: number | undefined): number | undefined {
|
|
158
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The caller's body streamed through unchanged while its bytes are counted. Nothing is
|
|
163
|
+
* buffered: each chunk goes to the upstream as it arrives, and crossing `limit` errors the
|
|
164
|
+
* stream, which fails the upstream fetch.
|
|
165
|
+
*/
|
|
166
|
+
function byteCappedRequestBody(
|
|
167
|
+
body: ReadableStream<Uint8Array>,
|
|
168
|
+
limit: number,
|
|
169
|
+
onOverflow: () => void,
|
|
170
|
+
onCancel: () => void,
|
|
171
|
+
): ReadableStream<Uint8Array> {
|
|
172
|
+
const reader = body.getReader();
|
|
173
|
+
let bytes = 0;
|
|
174
|
+
return new ReadableStream<Uint8Array>({
|
|
175
|
+
async pull(controller) {
|
|
176
|
+
try {
|
|
177
|
+
const next = await reader.read();
|
|
178
|
+
if (next.done) {
|
|
179
|
+
controller.close();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
bytes += next.value.byteLength;
|
|
183
|
+
if (bytes > limit) {
|
|
184
|
+
const error = new RangeError("link relay request body too large");
|
|
185
|
+
onOverflow();
|
|
186
|
+
try { await reader.cancel(error); } catch { /* best effort */ }
|
|
187
|
+
controller.error(error);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
controller.enqueue(next.value);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
controller.error(error);
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
async cancel(reason) {
|
|
196
|
+
onCancel();
|
|
197
|
+
try { await reader.cancel(reason); } catch { /* best effort */ }
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The connection itself was refused: nothing reached the Home, so the request may be sent again.
|
|
204
|
+
* Any other failure (a reset, a timeout, an error after the body started) may have reached it.
|
|
205
|
+
*/
|
|
206
|
+
function connectionRefused(error: unknown): boolean {
|
|
207
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
208
|
+
return code === "ConnectionRefused" || code === "ECONNREFUSED";
|
|
209
|
+
}
|
|
210
|
+
|
|
69
211
|
function idleBoundedStream(
|
|
70
212
|
body: ReadableStream<Uint8Array>,
|
|
71
213
|
signal: AbortSignal,
|
|
@@ -159,51 +301,92 @@ export async function relayLinkDataRequest(
|
|
|
159
301
|
if (!linkRouteAllowed(url, req)) return jsonError(404, "not_found");
|
|
160
302
|
let destination: string;
|
|
161
303
|
try { destination = linkRelayDestination(url, target); } catch { return jsonError(404, "not_found"); }
|
|
162
|
-
const validation = validateHubRelayRequestHeaders(
|
|
304
|
+
const validation = validateHubRelayRequestHeaders(linkFramingHeaders(req.headers));
|
|
163
305
|
if (!validation.ok) return jsonError(400, "link relay request headers refused");
|
|
164
306
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
} catch {
|
|
307
|
+
const bodyLimit = positive(deps.bodyLimitBytes) ?? LINK_RELAY_BODY_MAX_BYTES;
|
|
308
|
+
// The check admits at most one all-digit Content-Length, and Transfer-Encoding only as a lone
|
|
309
|
+
// `chunked` without one; the byte cap below bounds a chunked body instead.
|
|
310
|
+
const declaredLength = req.headers.get("content-length")?.trim() ?? null;
|
|
311
|
+
if (declaredLength !== null && Number(declaredLength) > bodyLimit) {
|
|
171
312
|
return jsonError(413, "link relay request body too large");
|
|
172
313
|
}
|
|
173
|
-
const headers =
|
|
314
|
+
const headers = linkRequestHeaders(req.headers, target.admissionKey, url.pathname, validation.connectionNamed);
|
|
174
315
|
if (!headersWithinLimit(headers)) {
|
|
175
316
|
return jsonError(431, "link relay request headers too large");
|
|
176
317
|
}
|
|
177
318
|
|
|
178
319
|
const relayAbort = new AbortController();
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
320
|
+
const clock = deps.clock ?? defaultClock;
|
|
321
|
+
let headerTimer: ReturnType<typeof setTimeout> | undefined = clock.setTimeout(() => {
|
|
322
|
+
headerTimer = undefined;
|
|
323
|
+
relayAbort.abort(new DOMException("link relay header deadline", "TimeoutError"));
|
|
324
|
+
}, positive(deps.headerTimeoutMs) ?? LINK_RELAY_HEADER_TIMEOUT_MS);
|
|
325
|
+
const stopHeaderDeadline = () => {
|
|
326
|
+
if (headerTimer !== undefined) clock.clearTimeout(headerTimer);
|
|
327
|
+
headerTimer = undefined;
|
|
328
|
+
};
|
|
184
329
|
const onClientAbort = () => relayAbort.abort(req.signal.reason);
|
|
185
|
-
timeoutSignal.addEventListener("abort", onTimeout, { once: true });
|
|
186
330
|
req.signal.addEventListener("abort", onClientAbort, { once: true });
|
|
187
331
|
const cleanup = () => {
|
|
188
|
-
|
|
332
|
+
stopHeaderDeadline();
|
|
189
333
|
req.signal.removeEventListener("abort", onClientAbort);
|
|
190
334
|
};
|
|
191
335
|
if (req.signal.aborted) onClientAbort();
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
let
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
336
|
+
|
|
337
|
+
let bodyOverflow = false;
|
|
338
|
+
let bodyCancelled = false;
|
|
339
|
+
const body = req.method === "GET" || req.method === "HEAD" || !req.body
|
|
340
|
+
? null
|
|
341
|
+
: byteCappedRequestBody(req.body, bodyLimit, () => { bodyOverflow = true; }, () => { bodyCancelled = true; });
|
|
342
|
+
// A streamed body keeps the caller's Content-Length, so the Home sees the same framing a
|
|
343
|
+
// buffered body produced instead of a chunked upload.
|
|
344
|
+
if (body && declaredLength !== null) headers.set("content-length", declaredLength);
|
|
345
|
+
|
|
346
|
+
// The hold: only while the tunnel is connecting or reconnecting, and at most `holdMs` from the
|
|
347
|
+
// first wait. A positive connected verdict is required again before every fetch.
|
|
348
|
+
const tunnel = deps.tunnel;
|
|
349
|
+
let holdUntil: number | undefined;
|
|
350
|
+
const holdForTunnel = async (gate: LinkTunnelGate): Promise<boolean> => {
|
|
351
|
+
const clockNow = deps.now ?? Date.now;
|
|
352
|
+
const now = clockNow();
|
|
353
|
+
holdUntil ??= now + (positive(deps.holdMs) ?? LINK_RELAY_HOLD_MS);
|
|
354
|
+
const remaining = holdUntil - now;
|
|
355
|
+
return remaining > 0 && await gate.waitForConnected(remaining, relayAbort.signal);
|
|
356
|
+
};
|
|
357
|
+
const tunnelUnavailable = async (): Promise<Response> => {
|
|
205
358
|
cleanup();
|
|
359
|
+
try { await body?.cancel(); } catch { /* best effort */ }
|
|
206
360
|
return jsonError(503, "link tunnel unavailable", true);
|
|
361
|
+
};
|
|
362
|
+
const readyForFetch = async (): Promise<boolean> => {
|
|
363
|
+
if (!tunnel) return false;
|
|
364
|
+
if (await tunnel.connected()) return true;
|
|
365
|
+
return tunnel.pending() && await holdForTunnel(tunnel) && await tunnel.connected();
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
let upstream: Response | undefined;
|
|
369
|
+
while (!upstream) {
|
|
370
|
+
if (!await readyForFetch()) return await tunnelUnavailable();
|
|
371
|
+
try {
|
|
372
|
+
const init: RequestInit & { duplex?: "half" } = {
|
|
373
|
+
method: req.method,
|
|
374
|
+
headers,
|
|
375
|
+
redirect: "manual",
|
|
376
|
+
signal: relayAbort.signal,
|
|
377
|
+
...(body ? { body, duplex: "half" } : {}),
|
|
378
|
+
};
|
|
379
|
+
upstream = await (deps.fetchImpl ?? fetch)(destination, init);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
if (bodyOverflow) {
|
|
382
|
+
cleanup();
|
|
383
|
+
return jsonError(413, "link relay request body too large");
|
|
384
|
+
}
|
|
385
|
+
// A refused connection sent nothing, and an untouched body can go again once the tunnel
|
|
386
|
+
// is back inside the same hold window. Anything else is never replayed.
|
|
387
|
+
const resendable = connectionRefused(error) && (body === null || (!body.locked && !bodyCancelled));
|
|
388
|
+
if (!tunnel || !resendable || !tunnel.pending() || !await holdForTunnel(tunnel)) return await tunnelUnavailable();
|
|
389
|
+
}
|
|
207
390
|
}
|
|
208
391
|
if (relayAbort.signal.aborted) {
|
|
209
392
|
cleanup();
|
|
@@ -218,9 +401,9 @@ export async function relayLinkDataRequest(
|
|
|
218
401
|
try { await upstream.body?.cancel(); } catch { /* best effort */ }
|
|
219
402
|
return jsonError(502, "link relay response headers too large");
|
|
220
403
|
}
|
|
221
|
-
const
|
|
222
|
-
if (!sse &&
|
|
223
|
-
|| Number(
|
|
404
|
+
const responseLength = upstream.headers.get("content-length");
|
|
405
|
+
if (!sse && responseLength !== null && (!/^\d+$/.test(responseLength)
|
|
406
|
+
|| Number(responseLength) > bodyLimit)) {
|
|
224
407
|
cleanup();
|
|
225
408
|
try { await upstream.body?.cancel(); } catch { /* best effort */ }
|
|
226
409
|
return jsonError(502, "link relay response body too large");
|
|
@@ -231,11 +414,11 @@ export async function relayLinkDataRequest(
|
|
|
231
414
|
return new Response(null, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders });
|
|
232
415
|
}
|
|
233
416
|
|
|
234
|
-
// The
|
|
235
|
-
|
|
417
|
+
// The header deadline ends once a response exists. The body owns cleanup after that.
|
|
418
|
+
stopHeaderDeadline();
|
|
236
419
|
const responseBody = sse
|
|
237
|
-
? idleBoundedStream(upstream.body, relayAbort.signal,
|
|
420
|
+
? idleBoundedStream(upstream.body, relayAbort.signal, clock,
|
|
238
421
|
deps.sseIdleTimeoutMs ?? LINK_RELAY_SSE_IDLE_TIMEOUT_MS, () => relayAbort.abort(new DOMException("link relay SSE idle timeout", "TimeoutError")), cleanup)
|
|
239
|
-
: boundedRelayResponseStream(upstream.body,
|
|
422
|
+
: boundedRelayResponseStream(upstream.body, bodyLimit, relayAbort.signal, cleanup);
|
|
240
423
|
return new Response(responseBody, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders });
|
|
241
424
|
}
|
package/src/client/link-state.ts
CHANGED
|
@@ -38,6 +38,52 @@ export function clientLinkStatePath(configDir?: string): string {
|
|
|
38
38
|
return join(linkDir(configDir), "client-link.json");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Durable evidence that this machine joined its link itself. It survives a lost sidecar, so a
|
|
43
|
+
* Child-initiated link whose sidecar is gone fails closed instead of being mistaken for a
|
|
44
|
+
* Home-initiated one, which the relay forwards without a tunnel ownership proof.
|
|
45
|
+
*/
|
|
46
|
+
export function childLinkMarkerPath(configDir?: string): string {
|
|
47
|
+
return join(linkDir(configDir), "child-initiated.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True when the marker names `linkId`, or when a marker exists but cannot be read. */
|
|
51
|
+
export function isChildInitiatedLink(linkId: string, path: string = childLinkMarkerPath()): boolean {
|
|
52
|
+
let text: string;
|
|
53
|
+
try {
|
|
54
|
+
text = readFileSync(path, "utf8");
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return !isMissingPathError(error);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(text) as { linkId?: unknown };
|
|
60
|
+
return typeof raw?.linkId !== "string" || raw.linkId === linkId;
|
|
61
|
+
} catch {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Record the marker for a join made before markers existed. Called at runtime start while the
|
|
68
|
+
* sidecar is still intact, so a sidecar deleted afterwards cannot turn that link into a
|
|
69
|
+
* Home-initiated one. A missing or unreadable sidecar records nothing.
|
|
70
|
+
*/
|
|
71
|
+
export function recordChildInitiatedLink(
|
|
72
|
+
linkId: string,
|
|
73
|
+
path: string = clientLinkStatePath(),
|
|
74
|
+
marker: string = join(dirname(path), "child-initiated.json"),
|
|
75
|
+
): void {
|
|
76
|
+
let sidecar: ClientLinkState | null;
|
|
77
|
+
try {
|
|
78
|
+
sidecar = readClientLinkState(path);
|
|
79
|
+
} catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!sidecar || sidecar.linkId !== linkId || isChildInitiatedLink(linkId, marker)) return;
|
|
83
|
+
atomicWriteFile(marker, `${JSON.stringify({ linkId })}\n`);
|
|
84
|
+
if (process.platform !== "win32") chmodSync(marker, 0o600);
|
|
85
|
+
}
|
|
86
|
+
|
|
41
87
|
export function parseClientLinkState(value: unknown): ClientLinkState {
|
|
42
88
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ClientLinkStateError("client-link.json is not an object");
|
|
43
89
|
const raw = value as Record<string, unknown>;
|
|
@@ -91,6 +137,9 @@ export function writeClientLinkState(state: ClientLinkState, path: string = clie
|
|
|
91
137
|
// the explicit mode on the final path.
|
|
92
138
|
atomicWriteFile(path, `${JSON.stringify(normalized, null, 2)}\n`);
|
|
93
139
|
if (process.platform !== "win32") chmodSync(path, 0o600);
|
|
140
|
+
const marker = join(dir, "child-initiated.json");
|
|
141
|
+
atomicWriteFile(marker, `${JSON.stringify({ linkId: normalized.linkId })}\n`);
|
|
142
|
+
if (process.platform !== "win32") chmodSync(marker, 0o600);
|
|
94
143
|
}
|
|
95
144
|
|
|
96
145
|
/**
|
|
@@ -106,5 +155,10 @@ export function clearClientLinkState(expectedLinkId: string, path: string = clie
|
|
|
106
155
|
if (isMissingPathError(error)) return false;
|
|
107
156
|
throw error;
|
|
108
157
|
}
|
|
158
|
+
try {
|
|
159
|
+
unlinkSync(join(dirname(path), "child-initiated.json"));
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (!isMissingPathError(error)) throw error;
|
|
162
|
+
}
|
|
109
163
|
return true;
|
|
110
164
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { LinkStatusDto } from "../link/status-projection";
|
|
2
|
+
import type { ClientLinkState } from "./link-state";
|
|
3
|
+
import type { ClientLinkSupervisorStatus } from "./link-tunnel";
|
|
4
|
+
|
|
5
|
+
/** What the status route read from the client sidecar: the state, none, or an unreadable file. */
|
|
6
|
+
export type ClientLinkSidecarRead = ClientLinkState | null | "invalid";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The K16 status document of a connected Child, built from its own sidecar and tunnel
|
|
10
|
+
* supervisor. A client-initiated Child has no `links.json` record, so the Home-side projection
|
|
11
|
+
* (`projectLinkStatus`) cannot describe it; this one runs in the Child's own listener instead.
|
|
12
|
+
* A Home-initiated Child has no sidecar and reports `child: null`.
|
|
13
|
+
*/
|
|
14
|
+
export function projectClientLinkChild(
|
|
15
|
+
sidecar: ClientLinkSidecarRead,
|
|
16
|
+
supervisor: ClientLinkSupervisorStatus,
|
|
17
|
+
now: number,
|
|
18
|
+
): LinkStatusDto {
|
|
19
|
+
return { role: "child", listener: { state: "off", port: null }, links: [], child: childState(sidecar, supervisor, now) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function childState(sidecar: ClientLinkSidecarRead, supervisor: ClientLinkSupervisorStatus, now: number): LinkStatusDto["child"] {
|
|
23
|
+
const alias = sidecar && sidecar !== "invalid" ? sidecar.alias : "unknown";
|
|
24
|
+
if (sidecar === "invalid" || supervisor.kind === "failed") {
|
|
25
|
+
return { alias, state: "failed", since: new Date(now).toISOString(), reason: "sidecar_invalid" };
|
|
26
|
+
}
|
|
27
|
+
if (!sidecar) return null;
|
|
28
|
+
if (supervisor.kind === "stopped") return { alias, state: "idle", since: new Date(now).toISOString(), reason: null };
|
|
29
|
+
const tunnel = supervisor.state;
|
|
30
|
+
return {
|
|
31
|
+
alias,
|
|
32
|
+
state: tunnel.kind,
|
|
33
|
+
since: new Date(tunnel.kind === "idle" ? now : tunnel.since).toISOString(),
|
|
34
|
+
// The keyed probe's finding (the Home refused the key, did not answer, or reports its own
|
|
35
|
+
// readiness as not ready) names the cause better than the tunnel state, so it wins; otherwise
|
|
36
|
+
// a failed tunnel shows its own reason.
|
|
37
|
+
reason: supervisor.probe ?? (tunnel.kind === "failed" ? tunnel.reason : null),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildExecArgv } from "../link/ssh-argv";
|
|
1
|
+
import { buildExecArgv, remoteOcxArgv } from "../link/ssh-argv";
|
|
2
2
|
import type { SshRunner } from "../link/ssh-runner";
|
|
3
3
|
import type { ClientLinkState } from "./link-state";
|
|
4
4
|
import type { OrphanTunnelResult } from "./link-tunnel";
|
|
@@ -47,7 +47,7 @@ export async function teardownClientLink(
|
|
|
47
47
|
const result = await deps.runner.run(
|
|
48
48
|
buildExecArgv({
|
|
49
49
|
alias: sidecar.alias,
|
|
50
|
-
argv: ["
|
|
50
|
+
argv: remoteOcxArgv(["link", "revoke", "--link-id", sidecar.linkId]),
|
|
51
51
|
knownHostsFile: deps.knownHostsFile,
|
|
52
52
|
}),
|
|
53
53
|
{ timeoutMs: deps.timeoutMs ?? HOME_REVOKE_TIMEOUT_MS },
|