@bitkyc08/opencodex 2.89.0 → 2.91.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/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/App-nZ34cR5G.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
- package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +28 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +7 -2
- package/src/adapters/openai-responses/request-strips.ts +32 -0
- package/src/adapters/openai-responses/transport.ts +19 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +36 -4
- package/src/chat/reasoning-details.ts +137 -0
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/cli/capabilities-agents-routing.ts +50 -0
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -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 +84 -9
- package/src/cli/provider.ts +44 -2
- 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/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-parity-reporter.ts +107 -0
- package/src/client/link-relay-transport.ts +8 -1
- package/src/client/link-relay.ts +27 -3
- package/src/client/machine-listener.ts +83 -1
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +137 -25
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +58 -12
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/combos/failover.ts +8 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -2
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/generated/compatibility-version.json +360 -192
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/service-secrets.ts +37 -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 +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- 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/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +24 -2
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +2 -1
- 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/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +240 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +8 -10
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- 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/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- 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 +6 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +8 -1
- package/src/server/chat-native.ts +8 -7
- 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 +11 -2
- package/src/server/index/optional-listeners.ts +7 -2
- package/src/server/index/serve-options.ts +27 -4
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/live.ts +5 -0
- 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 +12 -1
- package/src/server/management/oauth-account-routes.ts +2 -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/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +4 -2
- package/src/server/responses/core-auth.ts +31 -1
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +75 -1
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +85 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +19 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/diagnostics.ts +20 -6
- package/src/service/orchestration.ts +17 -0
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +7 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import type { OcxConfig } from "../types";
|
|
3
3
|
import { canonicalGuiBrowserOrigin, standaloneGuiPairingOrigin } from "../lib/gui-pair-capability";
|
|
4
|
+
import { authorizationCredential } from "../lib/authorization-scheme";
|
|
4
5
|
import {
|
|
5
6
|
isAllowedManagementOrigin,
|
|
6
7
|
isApiAuthRequired,
|
|
@@ -409,7 +410,7 @@ function isPairingTransportPermitted(origin: string): boolean {
|
|
|
409
410
|
|
|
410
411
|
function requestCredential(req: Request): string | null {
|
|
411
412
|
return req.headers.get("x-opencodex-api-key")?.trim()
|
|
412
|
-
|| req.headers
|
|
413
|
+
|| authorizationCredential(req.headers)
|
|
413
414
|
|| null;
|
|
414
415
|
}
|
|
415
416
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createLinkRelaySessions } from "./link-relay-sessions";
|
|
2
|
+
import { createLinkPeerObservations, type LinkPeerEntry } from "../../link/peer-report";
|
|
2
3
|
import type { Server } from "bun";
|
|
3
4
|
import {
|
|
4
5
|
emptyLinkStore,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
import { linkStorePath } from "../../link/paths";
|
|
10
11
|
import { linkRouteAllowed } from "../../link/routes";
|
|
11
12
|
import { withConfigMutationLockSync } from "../../config/mutation-lock";
|
|
13
|
+
import { registerFallbackLinkResolver } from "../../routing/fallback-policy";
|
|
12
14
|
|
|
13
15
|
export const LINK_INGRESS_HOSTNAME = "opencodex-link.invalid";
|
|
14
16
|
|
|
@@ -51,6 +53,8 @@ export interface LinkListenerLifecycle<T> {
|
|
|
51
53
|
linkAdmissionKeyIds(): ReadonlySet<string>;
|
|
52
54
|
onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
|
|
53
55
|
notifyAuthenticatedCatalog(apiKeyId: string): void;
|
|
56
|
+
/** What this Home last learned of a Child over the link; empty until it relays a request. */
|
|
57
|
+
peerObservation(linkId: string): LinkPeerEntry;
|
|
54
58
|
status(): LinkListenerStatus;
|
|
55
59
|
close(): Promise<void>;
|
|
56
60
|
stop(): Promise<void>;
|
|
@@ -76,6 +80,7 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
76
80
|
let stopped = false;
|
|
77
81
|
const authenticatedCatalogListeners = new Set<(apiKeyId: string) => void>();
|
|
78
82
|
let lifecycleStatus: LinkListenerStatus = { state: "off", port: null, reason: null };
|
|
83
|
+
const peers = createLinkPeerObservations();
|
|
79
84
|
|
|
80
85
|
const setStatus = (state: LinkListenerStatus["state"], port: number | null, reason: string | null): void => {
|
|
81
86
|
lifecycleStatus = { state, port, reason };
|
|
@@ -89,6 +94,9 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
89
94
|
const readStoreForAdmission = (): LinkStore => {
|
|
90
95
|
try { return readStore(storePath); } catch { return emptyLinkStore(); }
|
|
91
96
|
};
|
|
97
|
+
// OCX-11: the fallback policy resolves a link data key to its link id through this slot.
|
|
98
|
+
const releaseFallbackLinkResolver = registerFallbackLinkResolver(keyId =>
|
|
99
|
+
readStoreForAdmission().links.find(link => link.apiKeyId === keyId)?.id);
|
|
92
100
|
|
|
93
101
|
const bindIfNeeded = (): void => {
|
|
94
102
|
if (stopped || listener || !startContext) return;
|
|
@@ -104,7 +112,7 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
104
112
|
return;
|
|
105
113
|
}
|
|
106
114
|
const requestedPort = store.listenerPort ?? 0;
|
|
107
|
-
const sessions = createLinkRelaySessions({ fingerprints: (keyId, linkId) => {
|
|
115
|
+
const sessions = createLinkRelaySessions({ peers, fingerprints: (keyId, linkId) => {
|
|
108
116
|
if (!readStoreForAdmission().links.some(link => link.id === linkId && link.apiKeyId === keyId)) return [];
|
|
109
117
|
return startContext?.keyFingerprints?.(keyId) ?? [];
|
|
110
118
|
} });
|
|
@@ -214,12 +222,14 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
214
222
|
notifyAuthenticatedCatalog(apiKeyId) {
|
|
215
223
|
for (const listener of authenticatedCatalogListeners) listener(apiKeyId);
|
|
216
224
|
},
|
|
225
|
+
peerObservation: linkId => peers.get(linkId),
|
|
217
226
|
status() {
|
|
218
227
|
return { ...lifecycleStatus };
|
|
219
228
|
},
|
|
220
229
|
close,
|
|
221
230
|
async stop() {
|
|
222
231
|
stopped = true;
|
|
232
|
+
releaseFallbackLinkResolver();
|
|
223
233
|
// Process shutdown is deliberately forceful, unlike last-link removal. Every
|
|
224
234
|
// authenticated socket is closed, and the client transport cannot reconnect it.
|
|
225
235
|
relaySessions?.abortReservations();
|
|
@@ -14,22 +14,69 @@ import type { ManagementSessionControl } from "../management-auth";
|
|
|
14
14
|
import { runPassiveRead } from "../../lib/passive-read";
|
|
15
15
|
import { LINK_CODEX_SETTINGS_PATH, linkManagementRouteAllowed, linkManagementTarget } from "../../link/management-routes";
|
|
16
16
|
import { projectHomeCodexSettings } from "../../link/codex-settings";
|
|
17
|
+
import { homeLinkChildParity, LINK_CHILD_PARITY_MAX_BYTES, LINK_CHILD_PARITY_PATH, parseLinkChildParityReport } from "../../link/child-parity";
|
|
18
|
+
import { linkStorePath } from "../../link/paths";
|
|
19
|
+
import { readLinkStore } from "../../link/store";
|
|
20
|
+
import { readBoundedResponseBytes } from "../../lib/bounded-body";
|
|
17
21
|
import { packageVersion } from "../../lib/package-version";
|
|
18
22
|
|
|
19
23
|
const VERSION = packageVersion("0.0.0");
|
|
20
24
|
const KEPT_RESPONSE_HEADERS = ["content-type", "content-language", "etag", "last-modified"];
|
|
25
|
+
const REPORT_READ_TIMEOUT_MS = 5_000;
|
|
21
26
|
|
|
22
27
|
function notFound(): Response {
|
|
23
28
|
return Response.json({ error: "not_found" }, { status: 404, headers: { "cache-control": "no-store" } });
|
|
24
29
|
}
|
|
25
30
|
|
|
31
|
+
function refused(status: number, code: string): Response {
|
|
32
|
+
return Response.json({ error: code }, { status, headers: { "cache-control": "no-store" } });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `POST` {@link LINK_CHILD_PARITY_PATH}: the Child's apply report (OCX-6). The one write the link
|
|
37
|
+
* accepts, handled here, before and apart from the read-only table, so it never widens what a
|
|
38
|
+
* read may do. It is admitted only for the link the admitting key belongs to, reads at most
|
|
39
|
+
* {@link LINK_CHILD_PARITY_MAX_BYTES} and changes nothing but the in-memory report.
|
|
40
|
+
*/
|
|
41
|
+
async function handleChildParityReport(req: Request, url: URL, admittedKeyId: string | undefined): Promise<Response> {
|
|
42
|
+
if (req.method !== "POST" || url.search !== "" || req.headers.has("upgrade")) return notFound();
|
|
43
|
+
if (!admittedKeyId) return refused(403, "link_key_required");
|
|
44
|
+
const declared = Number(req.headers.get("content-length") ?? "0");
|
|
45
|
+
if (Number.isFinite(declared) && declared > LINK_CHILD_PARITY_MAX_BYTES) return refused(413, "report_too_large");
|
|
46
|
+
let bytes: Uint8Array;
|
|
47
|
+
try {
|
|
48
|
+
const read = await readBoundedResponseBytes(new Response(req.body), {
|
|
49
|
+
maxBytes: LINK_CHILD_PARITY_MAX_BYTES,
|
|
50
|
+
signal: AbortSignal.timeout(REPORT_READ_TIMEOUT_MS),
|
|
51
|
+
});
|
|
52
|
+
if (read.oversized) return refused(413, "report_too_large");
|
|
53
|
+
bytes = read.bytes;
|
|
54
|
+
} catch {
|
|
55
|
+
return refused(400, "invalid_report");
|
|
56
|
+
}
|
|
57
|
+
let report: ReturnType<typeof parseLinkChildParityReport>;
|
|
58
|
+
try {
|
|
59
|
+
report = parseLinkChildParityReport(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
|
|
60
|
+
} catch {
|
|
61
|
+
report = null;
|
|
62
|
+
}
|
|
63
|
+
if (!report) return refused(400, "invalid_report");
|
|
64
|
+
let links: ReturnType<typeof readLinkStore>["links"];
|
|
65
|
+
try { links = readLinkStore(linkStorePath()).links; } catch { return refused(503, "link_store_unavailable"); }
|
|
66
|
+
if (!links.some(link => link.id === report.linkId && link.apiKeyId === admittedKeyId)) return refused(403, "link_mismatch");
|
|
67
|
+
homeLinkChildParity.record(report, new Set(links.map(link => link.id)));
|
|
68
|
+
return new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
|
|
69
|
+
}
|
|
70
|
+
|
|
26
71
|
export async function handleLinkManagementRequest(
|
|
27
72
|
req: Request,
|
|
28
73
|
url: URL,
|
|
29
74
|
config: OcxConfig,
|
|
30
75
|
deps: ManagementApiDeps,
|
|
31
76
|
sessionControl?: ManagementSessionControl,
|
|
77
|
+
admittedKeyId?: string,
|
|
32
78
|
): Promise<Response> {
|
|
79
|
+
if (url.pathname === LINK_CHILD_PARITY_PATH) return handleChildParityReport(req, url, admittedKeyId);
|
|
33
80
|
if (!linkManagementRouteAllowed(url, req)) return notFound();
|
|
34
81
|
if (url.pathname === LINK_CODEX_SETTINGS_PATH) {
|
|
35
82
|
return Response.json(projectHomeCodexSettings(config, VERSION), { headers: { "cache-control": "no-store" } });
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION,
|
|
4
4
|
LINK_RELAY_SESSION_HEADER, linkRelayProof, linkRelayProofMatches,
|
|
5
5
|
} from "../../link/relay-auth";
|
|
6
|
+
import { parseLinkPeerReport, type LinkPeerObservations } from "../../link/peer-report";
|
|
6
7
|
|
|
7
8
|
interface Session {
|
|
8
9
|
peer: string;
|
|
@@ -18,6 +19,8 @@ export interface LinkRelaySessionOptions {
|
|
|
18
19
|
fingerprints: (keyId: string, linkId: string) => readonly string[];
|
|
19
20
|
/** Tests can use a bounded shorter reservation; production uses five seconds. */
|
|
20
21
|
reservationMs?: number;
|
|
22
|
+
/** Where a verified challenge records the Child's report, and a known link's version mismatch. */
|
|
23
|
+
peers?: LinkPeerObservations;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
/** One book per bound listener. A pending proof reserves the bind before the data request arrives. */
|
|
@@ -64,14 +67,20 @@ export function createLinkRelaySessions(options: LinkRelaySessionOptions) {
|
|
|
64
67
|
if (closing || req.method !== "GET" || sessions.size >= 256 || req.headers.has("upgrade")
|
|
65
68
|
|| req.headers.has("authorization") || req.headers.has("x-opencodex-api-key")) return refuse();
|
|
66
69
|
const q = url.searchParams;
|
|
67
|
-
if (q.
|
|
68
|
-
|| ["version", "key", "link", "nonce", "proof"].some(name => q.getAll(name).length !== 1)) return refuse();
|
|
70
|
+
if ([...q.keys()].length !== 5 || ["version", "key", "link", "nonce", "proof"].some(name => q.getAll(name).length !== 1)) return refuse();
|
|
69
71
|
const keyId = q.get("key")!, linkId = q.get("link")!, nonce = q.get("nonce")!;
|
|
72
|
+
if (q.get("version") !== LINK_RELAY_AUTH_VERSION) {
|
|
73
|
+
// Unverifiable, but only for a link and key this Home issued: status names the mismatch.
|
|
74
|
+
const version = q.get("version")!;
|
|
75
|
+
if (/^[1-9]\d{0,3}$/.test(version) && options.fingerprints(keyId, linkId).length > 0) options.peers?.refuse(linkId, version);
|
|
76
|
+
return refuse();
|
|
77
|
+
}
|
|
70
78
|
const peer = peerOf(req, server as Server<unknown>);
|
|
71
79
|
if (!peer || sessions.has(nonce)) return refuse();
|
|
72
80
|
const fingerprint = options.fingerprints(keyId, linkId).find(candidate =>
|
|
73
81
|
linkRelayProofMatches(q.get("proof"), linkRelayProof(candidate, "caller", keyId, linkId, nonce)));
|
|
74
82
|
if (!fingerprint) return refuse();
|
|
83
|
+
options.peers?.observe(linkId, LINK_RELAY_AUTH_VERSION, parseLinkPeerReport(req.headers));
|
|
75
84
|
const release = acquire();
|
|
76
85
|
const timer = setTimeout(() => { consume(nonce)?.release(); }, reservationMs);
|
|
77
86
|
sessions.set(nonce, { peer, keyId, linkId, fingerprint, release, timer, expiresAt: Date.now() + reservationMs });
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "./link-listener";
|
|
16
16
|
import { createLinkSupervisor, type LinkSupervisor } from "../../link/supervisor";
|
|
17
17
|
import { linkManagementRouteAllowed } from "../../link/management-routes";
|
|
18
|
+
import { linkChildParityRouteAllowed } from "../../link/child-parity";
|
|
18
19
|
import { saveConfigPreservingClaudeCode } from "../../config/live-reconcile";
|
|
19
20
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
20
21
|
import { startJevQuotaActivation } from "../../combos/jev-quota-activation";
|
|
@@ -38,6 +39,7 @@ export interface OptionalListenerSet<T> {
|
|
|
38
39
|
linkAdmissionKeyIds(): ReadonlySet<string>;
|
|
39
40
|
onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
|
|
40
41
|
notifyAuthenticatedCatalog(apiKeyId: string): void;
|
|
42
|
+
peerObservation(linkId: string): import("../../link/peer-report").LinkPeerEntry;
|
|
41
43
|
status(): LinkListenerStatus;
|
|
42
44
|
linkStatus(): LinkListenerStatus;
|
|
43
45
|
linkSupervisor(): LinkSupervisor;
|
|
@@ -83,11 +85,14 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
|
|
|
83
85
|
},
|
|
84
86
|
// The Home's link listener admits the data plane plus the read-only management relay
|
|
85
87
|
// (#6957). The Child's own ingress keeps the data-only table, so a local caller on the Child
|
|
86
|
-
// cannot reach the Home's management reads without the Child's dashboard session.
|
|
87
|
-
|
|
88
|
+
// cannot reach the Home's management reads without the Child's dashboard session. The one
|
|
89
|
+
// write is the Child's apply report (OCX-6), which changes only an in-memory status field.
|
|
90
|
+
linkRouteAllowed: (url, req) => linkRouteAllowed(url, req) || linkManagementRouteAllowed(url, req)
|
|
91
|
+
|| linkChildParityRouteAllowed(url, req),
|
|
88
92
|
linkAdmissionKeyIds: () => linkListener.linkAdmissionKeyIds(),
|
|
89
93
|
onAuthenticatedCatalog: listener => linkListener.onAuthenticatedCatalog(listener),
|
|
90
94
|
notifyAuthenticatedCatalog: apiKeyId => linkListener.notifyAuthenticatedCatalog(apiKeyId),
|
|
95
|
+
peerObservation: linkId => linkListener.peerObservation(linkId),
|
|
91
96
|
status: () => linkListener.status(),
|
|
92
97
|
linkStatus: () => linkListener.status(),
|
|
93
98
|
linkSupervisor: () => supervisor,
|
|
@@ -204,10 +204,13 @@ import type { ReadinessGate } from "../readiness";
|
|
|
204
204
|
import type { WorkflowRefusalLog } from "../workflow-refusal";
|
|
205
205
|
|
|
206
206
|
import { readyProtocolMetadata } from "../../remote/protocol";
|
|
207
|
+
import { homeInstanceId, stampServedBy } from "../../remote/served-by";
|
|
207
208
|
import { modelCapabilityFields } from "../models-capabilities";
|
|
208
209
|
import { createWebsocketHandler } from "./websocket-handler";
|
|
210
|
+
import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
|
|
209
211
|
import { withGrokSessionIdentity } from "../../grok/session-identity";
|
|
210
212
|
import { withCallerSessionIdentity } from "../caller-session-identity";
|
|
213
|
+
import { agentAssertionUnsupportedResponse, hasAgentAssertionAuthorization } from "../../lib/authorization-scheme";
|
|
211
214
|
|
|
212
215
|
export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management" | "claude-intercept" | "hub-link";
|
|
213
216
|
|
|
@@ -369,6 +372,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
369
372
|
: ingress === "unauthenticated-loopback" || ingress === "claude-intercept"
|
|
370
373
|
? loopbackPolicy()
|
|
371
374
|
: config;
|
|
375
|
+
// Codex runtime agent assertion_only (Authorization: AgentAssertion) is refused before
|
|
376
|
+
// admission or routing: it is never a bearer, never an admission secret, and no pool or
|
|
377
|
+
// routed credential may stand in for it.
|
|
378
|
+
if (hasAgentAssertionAuthorization(req.headers)) return withCors(agentAssertionUnsupportedResponse(), req, policy);
|
|
372
379
|
if (ingress === "hub-link") {
|
|
373
380
|
const admission = resolveApiAuth(req, policy);
|
|
374
381
|
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
@@ -379,7 +386,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
379
386
|
// the shared table in src/link/management-routes.ts; never reachable on another ingress.
|
|
380
387
|
if (isLinkManagementPath(requestUrl.pathname)) {
|
|
381
388
|
const { handleLinkManagementRequest } = await import("./link-management");
|
|
382
|
-
return withCors(await handleLinkManagementRequest(
|
|
389
|
+
return withCors(await handleLinkManagementRequest(
|
|
390
|
+
req, requestUrl, config, requestManagementApiDeps, managementSessionControl,
|
|
391
|
+
admission.kind === "configured" ? admission.keyId : undefined,
|
|
392
|
+
), req, policy);
|
|
383
393
|
}
|
|
384
394
|
}
|
|
385
395
|
const url = requestUrl;
|
|
@@ -407,11 +417,13 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
407
417
|
const message = "OpenCodex package files changed while this proxy was running; it restarts on its own, or run 'ocx restart' ('ocx service restart' for a background service).";
|
|
408
418
|
const fencedPort = ctx.boundPort ?? requestServer.port ?? listenPort;
|
|
409
419
|
const fencedHealth = url.pathname === "/healthz";
|
|
420
|
+
const fencedInstanceId = fencedHealth ? homeInstanceId() : undefined;
|
|
410
421
|
const response = fencedHealth || readyzPath !== undefined
|
|
411
422
|
? jsonResponse({
|
|
412
423
|
status: "restart_required",
|
|
413
424
|
service: "opencodex",
|
|
414
425
|
version: VERSION,
|
|
426
|
+
...(fencedInstanceId ? { instanceId: fencedInstanceId } : {}),
|
|
415
427
|
uptime: process.uptime(),
|
|
416
428
|
pid: process.pid,
|
|
417
429
|
port: fencedPort,
|
|
@@ -622,10 +634,13 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
622
634
|
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
623
635
|
// service/pid/port let CLI liveness reject foreign 200s and verify pid identity.
|
|
624
636
|
const healthPort = ctx.server.port ?? listenPort;
|
|
637
|
+
// Opaque Home instance id (src/remote/served-by.ts); omitted until the identity exists.
|
|
638
|
+
const instanceId = homeInstanceId();
|
|
625
639
|
const response = jsonResponse({
|
|
626
640
|
status: "ok",
|
|
627
641
|
service: "opencodex",
|
|
628
642
|
version: VERSION,
|
|
643
|
+
...(instanceId ? { instanceId } : {}),
|
|
629
644
|
uptime: process.uptime(),
|
|
630
645
|
pid: process.pid,
|
|
631
646
|
port: healthPort,
|
|
@@ -1347,8 +1362,9 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1347
1362
|
response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission, {
|
|
1348
1363
|
onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer),
|
|
1349
1364
|
});
|
|
1350
|
-
} catch {
|
|
1351
|
-
response =
|
|
1365
|
+
} catch (error) {
|
|
1366
|
+
response = reasoningArchiveErrorResponse(error)
|
|
1367
|
+
?? formatErrorResponse(500, "server_error", "Unexpected compact request failure");
|
|
1352
1368
|
}
|
|
1353
1369
|
addFinalRequestLog(requestId, start, logCtx, response.status,
|
|
1354
1370
|
response.status === 499 ? { closeReason: "client_cancel" } : undefined);
|
|
@@ -1519,6 +1535,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1519
1535
|
},
|
|
1520
1536
|
});
|
|
1521
1537
|
} catch (error) {
|
|
1538
|
+
const archiveError = reasoningArchiveErrorResponse(error);
|
|
1539
|
+
if (archiveError) return withRequestLogId(
|
|
1540
|
+
withCors(responseWithDeferredRequestLog(archiveError, requestId, start, logCtx), req, policy), requestId,
|
|
1541
|
+
);
|
|
1522
1542
|
// A bounded upstream body can fail before handleResponses returns a client response.
|
|
1523
1543
|
// Finalize before rethrow so accounting observes the physical send while the caller
|
|
1524
1544
|
// retains the existing reset/rejection instead of receiving a synthesized response.
|
|
@@ -1971,5 +1991,8 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1971
1991
|
},
|
|
1972
1992
|
websocket: createWebsocketHandler(ctx, requestMetrics),
|
|
1973
1993
|
} as const;
|
|
1974
|
-
|
|
1994
|
+
const routeFetch = serveOptions.fetch;
|
|
1995
|
+
// x-opencodex-served-by on GET /v1/models, whichever branch answered (src/remote/served-by.ts).
|
|
1996
|
+
return { ...serveOptions, fetch: async (req: Request, requestServer: Server<WsData>): Promise<Response> =>
|
|
1997
|
+
stampServedBy(req.method, codexCompatibleUrl(req.url).pathname, await routeFetch(req, requestServer)) } as const;
|
|
1975
1998
|
}
|
|
@@ -2,6 +2,7 @@ import { nativeSteeringUnavailableReason, nativeResponseControlMode, type Native
|
|
|
2
2
|
import { NativeInjectionChannel } from "../responses/native-injection";
|
|
3
3
|
import { NativeSteeringChannel, NativeSteeringError } from "../responses/native-steering";
|
|
4
4
|
import { createNativeSteeringLogObserver } from "../responses/native-steering-log";
|
|
5
|
+
import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
|
|
5
6
|
import type { Server, ServerWebSocket } from "bun";
|
|
6
7
|
import {
|
|
7
8
|
LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
|
|
@@ -396,6 +397,15 @@ export function createWebsocketHandler(
|
|
|
396
397
|
}));
|
|
397
398
|
return;
|
|
398
399
|
}
|
|
400
|
+
// A local archive refusal happens before any send. Carry the same sanitized 503
|
|
401
|
+
// body and stable code as HTTP so no filesystem path reaches the client frame.
|
|
402
|
+
const archiveError = reasoningArchiveErrorResponse(err);
|
|
403
|
+
if (archiveError) {
|
|
404
|
+
finalizeLog(archiveError.status);
|
|
405
|
+
const body = await archiveError.json() as { error: Record<string, unknown> };
|
|
406
|
+
sendJsonFrame(ws, buildWsErrorFrame(archiveError.status, body.error));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
399
409
|
finalizeLog(502);
|
|
400
410
|
sendJsonFrame(ws, buildWsErrorFrame(502, {
|
|
401
411
|
type: "proxy_error",
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -476,13 +476,25 @@ export function trackStreamLifetime(
|
|
|
476
476
|
registerTurn(ac, lease);
|
|
477
477
|
const reader = body.getReader();
|
|
478
478
|
let closed = false;
|
|
479
|
+
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
480
|
+
// A drain aborts the turn's controller; many callers pass a fresh one nothing else listens to,
|
|
481
|
+
// so the tracked stream itself has to stop.
|
|
482
|
+
const onAbort = () => {
|
|
483
|
+
if (closed) return;
|
|
484
|
+
// Stop the stream before the caller's onDone runs, so a throwing callback cannot leave it open.
|
|
485
|
+
reader.cancel(ac.signal.reason).catch(() => {});
|
|
486
|
+
try { streamController?.error(ac.signal.reason); } catch { /* already closed */ }
|
|
487
|
+
finish();
|
|
488
|
+
};
|
|
479
489
|
const finish = () => {
|
|
480
490
|
if (closed) return;
|
|
481
491
|
closed = true;
|
|
492
|
+
ac.signal.removeEventListener("abort", onAbort);
|
|
482
493
|
unregisterTurn(ac);
|
|
483
494
|
onDone?.();
|
|
484
495
|
};
|
|
485
|
-
|
|
496
|
+
const tracked = new ReadableStream<Uint8Array>({
|
|
497
|
+
start(controller) { streamController = controller; },
|
|
486
498
|
async pull(controller) {
|
|
487
499
|
try {
|
|
488
500
|
const { done, value } = await reader.read();
|
|
@@ -499,6 +511,10 @@ export function trackStreamLifetime(
|
|
|
499
511
|
reader.cancel(reason).catch(() => {});
|
|
500
512
|
},
|
|
501
513
|
});
|
|
514
|
+
// After registerTurn, so the lease's own abort listener (release the turn) runs first. A
|
|
515
|
+
// controller registerTurn already aborted (a settled lease) keeps its previous behaviour.
|
|
516
|
+
ac.signal.addEventListener("abort", onAbort, { once: true });
|
|
517
|
+
return tracked;
|
|
502
518
|
}
|
|
503
519
|
|
|
504
520
|
export async function drainAndShutdown(
|
package/src/server/live.ts
CHANGED
|
@@ -88,6 +88,10 @@ export const LIVE_SIDEBAND_API_ROOT = "https://api.openai.com/v1";
|
|
|
88
88
|
* upstream the caller was addressing, so there is nothing to scope it away from. That is not
|
|
89
89
|
* true of the images sidecar, which strips it deliberately and keeps doing so.
|
|
90
90
|
*
|
|
91
|
+
* `voice-session-id` is the client-generated voice session id Codex 0.164 forwards on WebSocket,
|
|
92
|
+
* WebRTC and existing-call transports (openai/codex#53784). It is a separate identity from the
|
|
93
|
+
* realtime session id carried in `x-session-id`, so neither may stand in for the other.
|
|
94
|
+
*
|
|
91
95
|
* Every name here is relayed only when the caller sent it. Nothing on this list is invented,
|
|
92
96
|
* which is what keeps a caller that omits one byte-identical upstream. Auth headers
|
|
93
97
|
* (`authorization`, `chatgpt-account-id`) stay proxy-owned and are never taken from this list.
|
|
@@ -100,6 +104,7 @@ export const LIVE_CLIENT_PROTOCOL_HEADERS = [
|
|
|
100
104
|
"originator",
|
|
101
105
|
"x-oai-attestation",
|
|
102
106
|
"x-codex-turn-metadata",
|
|
107
|
+
"voice-session-id",
|
|
103
108
|
] as const;
|
|
104
109
|
|
|
105
110
|
/**
|
|
@@ -156,7 +156,7 @@ export interface ManagementApiDeps {
|
|
|
156
156
|
codexPromptPaths?: CodexPromptPaths;
|
|
157
157
|
/** Link seams are getters so the optional listener and supervisor are singletons. */
|
|
158
158
|
linkSupervisor?: () => LinkSupervisor;
|
|
159
|
-
linkListener?: () => Pick<LinkListenerLifecycle<unknown>, "ensureStarted" | "status" | "close" | "onAuthenticatedCatalog"> & Partial<Pick<import("../index/optional-listeners").OptionalListenerSet<unknown>, "ensureClaudeIntercept" | "claudeInterceptOutcome">>;
|
|
159
|
+
linkListener?: () => Pick<LinkListenerLifecycle<unknown>, "ensureStarted" | "status" | "close" | "onAuthenticatedCatalog"> & Partial<Pick<LinkListenerLifecycle<unknown>, "peerObservation">> & Partial<Pick<import("../index/optional-listeners").OptionalListenerSet<unknown>, "ensureClaudeIntercept" | "claudeInterceptOutcome">>;
|
|
160
160
|
readLinkStore?: () => LinkStore;
|
|
161
161
|
writeLinkStore?: (store: LinkStore) => void;
|
|
162
162
|
linkKnownHostsPath?: () => string;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET|PUT /api/fallback`: the OCX-11 fallback state (`ocx.fallback/1`) and its one scoped write.
|
|
3
|
+
*
|
|
4
|
+
* GET is a passive read. A Remote Link Child reaches it through the read-only link relay
|
|
5
|
+
* (`LINK_MANAGEMENT_READ_PATHS`); there it drops link aliases (`buildFallbackState` passive). PUT
|
|
6
|
+
* runs on the Home only: a Child's relay and CLI refuse writes before sending.
|
|
7
|
+
*/
|
|
8
|
+
import type { ManagementContext } from "./context";
|
|
9
|
+
import { jsonResponse } from "../auth-cors";
|
|
10
|
+
import { mutatePersistedConfig } from "../../config";
|
|
11
|
+
import { isPassiveRead } from "../../lib/passive-read";
|
|
12
|
+
import { readLinkStore, type LinkRecord } from "../../link/store";
|
|
13
|
+
import { linkStorePath } from "../../link/paths";
|
|
14
|
+
import { applyFallbackWrite, buildFallbackState, parseFallbackWrites } from "../../routing/fallback-state";
|
|
15
|
+
import { managementBodyTooLargeResponse, readManagementJsonBody } from "./body";
|
|
16
|
+
|
|
17
|
+
function links(ctx: ManagementContext): LinkRecord[] {
|
|
18
|
+
try {
|
|
19
|
+
return (ctx.deps.readLinkStore?.() ?? readLinkStore(linkStorePath())).links;
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function view(ctx: ManagementContext): Response {
|
|
26
|
+
const response = jsonResponse(buildFallbackState(ctx.config, { links: links(ctx), passive: isPassiveRead() }), 200, ctx.req, ctx.config);
|
|
27
|
+
response.headers.set("cache-control", "no-store");
|
|
28
|
+
return response;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function handleFallbackRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
32
|
+
const { req, url, config } = ctx;
|
|
33
|
+
if (url.pathname === "/api/fallback" && req.method === "GET") return view(ctx);
|
|
34
|
+
if (url.pathname === "/api/fallback" && req.method === "PUT") return await writeFallback(ctx);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function writeFallback(ctx: ManagementContext): Promise<Response> {
|
|
39
|
+
const { req, config } = ctx;
|
|
40
|
+
let body: unknown;
|
|
41
|
+
try {
|
|
42
|
+
body = await readManagementJsonBody(req);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return managementBodyTooLargeResponse(error, req, config)
|
|
45
|
+
?? jsonResponse({ error: "invalid JSON body", code: "invalid_request" }, 400, req, config);
|
|
46
|
+
}
|
|
47
|
+
const parsed = parseFallbackWrites(body);
|
|
48
|
+
if (!parsed.ok) return jsonResponse({ error: parsed.error, code: "invalid_request" }, 400, req, config);
|
|
49
|
+
const known = links(ctx);
|
|
50
|
+
for (const write of parsed.writes) {
|
|
51
|
+
if (write.scope !== "device") continue;
|
|
52
|
+
// A link alias names its link; otherwise the key must be a known link id or data key id.
|
|
53
|
+
const byAlias = known.find(link => link.alias === write.key);
|
|
54
|
+
if (byAlias) write.key = byAlias.id;
|
|
55
|
+
// Clearing stays possible for a device that is gone (a revoked link, a deleted data key):
|
|
56
|
+
// its entry still lists in the state, and clearing a missing entry is a no-op.
|
|
57
|
+
if (write.value === null) continue;
|
|
58
|
+
const exists = known.some(link => link.id === write.key)
|
|
59
|
+
|| (config.apiKeys ?? []).some(entry => entry.id === write.key);
|
|
60
|
+
if (!exists) {
|
|
61
|
+
return jsonResponse({ error: "unknown device: use a link id or alias from ocx link status, or a Hub data key id", code: "unknown_device" }, 404, req, config);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const outcome = mutatePersistedConfig(persisted => {
|
|
65
|
+
let changed = false;
|
|
66
|
+
for (const write of parsed.writes) changed = applyFallbackWrite(persisted, write) || changed;
|
|
67
|
+
return { changed, value: persisted.fallback === undefined ? undefined : structuredClone(persisted.fallback) };
|
|
68
|
+
});
|
|
69
|
+
if (outcome.status === "unavailable") {
|
|
70
|
+
return jsonResponse({ error: "config.json is " + outcome.reason + "; fallback settings were not saved", code: "config_unavailable" }, 409, req, config);
|
|
71
|
+
}
|
|
72
|
+
if (outcome.value === undefined) delete config.fallback;
|
|
73
|
+
else config.fallback = outcome.value;
|
|
74
|
+
return view(ctx);
|
|
75
|
+
}
|
|
@@ -3,6 +3,8 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv, REMOTE_COMMAND_NOT_FOUND, REMOTE_PLATFORM_UNSUPPORTED, remoteOcxArgv } from "../../link/ssh-argv";
|
|
5
5
|
import { parseFingerprintLine } from "../../link/fingerprint";
|
|
6
|
+
import { detectRemotePlatform, isWindowsShellRefusal, WINDOWS_CHILD_REFUSAL } from "../../link/remote-platform";
|
|
7
|
+
import type { SshRunner } from "../../link/ssh-runner";
|
|
6
8
|
import { LINK_CATALOG_MAX_BYTES, LINK_RELAY_VERIFY_MS, isChildHealth, linkCommandBudget, linkRestartBudget, parseChildHttp, remoteChildHttpArgv, REMOTE_CURL_PREFLIGHT } from "../../link/remote-probe";
|
|
7
9
|
import { newLinkId, type LinkStore } from "../../link/store";
|
|
8
10
|
import { SshRunnerError, boundHint, sshFailureHint, sshRunnerErrorHint, type SshRunResult } from "../../link/ssh-runner";
|
|
@@ -24,11 +26,23 @@ async function serialized(state: LinkRouteState, alias: string, action: () => Pr
|
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
function remoteFailure(result: SshRunResult, code: string, message: string): Response | null {
|
|
27
|
-
if (result.code === REMOTE_PLATFORM_UNSUPPORTED
|
|
29
|
+
if (result.code === REMOTE_PLATFORM_UNSUPPORTED || (result.code !== 0 && isWindowsShellRefusal(result.stderr))) return windowsRefusal();
|
|
28
30
|
if (result.code === REMOTE_COMMAND_NOT_FOUND) return fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr));
|
|
29
31
|
return result.code === 0 ? null : fail(code, message, 502, sshFailureHint(result.stderr));
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
function windowsRefusal(): Response {
|
|
35
|
+
return fail("remote_platform_unsupported", WINDOWS_CHILD_REFUSAL, 409);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pre-issuance Windows gate for a failed remote command: exit 125, the English cmd/PowerShell
|
|
40
|
+
* refusal, or (for localized shells) the fixed %OS% probe against the same host key.
|
|
41
|
+
*/
|
|
42
|
+
async function windowsChild(runner: SshRunner, alias: string, knownHostsFile: string, result: SshRunResult): Promise<boolean> {
|
|
43
|
+
return result.code !== 0 && await detectRemotePlatform(runner, alias, knownHostsFile, result) === "windows";
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
export async function probe(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
33
47
|
const value = await readManagementJsonBodyOr(ctx.req, null);
|
|
34
48
|
const body = exactBody(value, ["alias"]) ?? exactBody(value, ["alias", "includeVersion"]);
|
|
@@ -44,7 +58,8 @@ export async function probe(ctx: ManagementContext, state: LinkRouteState): Prom
|
|
|
44
58
|
const started = now();
|
|
45
59
|
const result = await runner.run(buildProbeArgv({ alias, tempKnownHostsFile: path }), { timeoutMs: 30_000 });
|
|
46
60
|
const latency = Math.max(0, now() - started);
|
|
47
|
-
|
|
61
|
+
// cmd.exe and PowerShell refuse the POSIX `true` the probe runs; say Windows, not "probe failed".
|
|
62
|
+
if (result.code !== 0) return await windowsChild(runner, alias, path, result) ? windowsRefusal() : fail("probe_failed", "SSH host probing failed.", 502, sshFailureHint(result.stderr));
|
|
48
63
|
const fp = await runner.run(buildFingerprintArgv(path), { timeoutMs: 10_000 });
|
|
49
64
|
if (fp.code !== 0) return fail("fingerprint_failed", "The SSH host fingerprint could not be read.", 502, sshFailureHint(fp.stderr));
|
|
50
65
|
const parsed = parseFingerprintLine(fp.stdout.trim());
|
|
@@ -53,6 +68,7 @@ export async function probe(ctx: ManagementContext, state: LinkRouteState): Prom
|
|
|
53
68
|
let ocxVersion: string | undefined;
|
|
54
69
|
if (body.includeVersion === true) {
|
|
55
70
|
const versionResult = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
|
|
71
|
+
if (await windowsChild(runner, alias, path, versionResult)) return windowsRefusal();
|
|
56
72
|
const denied = remoteFailure(versionResult, "version_probe_failed", "The remote ocx version could not be read.");
|
|
57
73
|
if (denied) return denied;
|
|
58
74
|
const parsedVersion = parseVersion(versionResult.stdout);
|
|
@@ -84,7 +100,9 @@ export async function confirmHost(ctx: ManagementContext, state: LinkRouteState)
|
|
|
84
100
|
writeFileSync(path, pending.knownHostLine + "\n", { mode: 0o600 });
|
|
85
101
|
try {
|
|
86
102
|
const started = now();
|
|
87
|
-
const
|
|
103
|
+
const runner = runnerFor(ctx);
|
|
104
|
+
const result = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
|
|
105
|
+
if (await windowsChild(runner, alias, path, result)) return windowsRefusal();
|
|
88
106
|
const denied = remoteFailure(result, "version_probe_failed", "The remote ocx version could not be confirmed.");
|
|
89
107
|
if (denied) return denied;
|
|
90
108
|
const version = parseVersion(result.stdout);
|
|
@@ -27,6 +27,8 @@ export interface LinkRouteListener {
|
|
|
27
27
|
status(): { state: "off" | "listening" | "failed"; port: number | null; reason: string | null };
|
|
28
28
|
close(): Promise<void>;
|
|
29
29
|
onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
|
|
30
|
+
/** Optional so route fakes stay small; without it every link reports an unknown peer. */
|
|
31
|
+
peerObservation?(linkId: string): import("../../link/peer-report").LinkPeerEntry;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface LinkRouteState {
|
|
@@ -6,7 +6,11 @@ import { clearCompensationFailed, compensationPath, readCompensation } from "../
|
|
|
6
6
|
import { loadHostCandidates } from "../../link/ssh-config";
|
|
7
7
|
import { newLinkId, type LinkStore } from "../../link/store";
|
|
8
8
|
import { sshFailureHint, sshRunnerErrorHint } from "../../link/ssh-runner";
|
|
9
|
-
import { projectLinkStatus, type LinkStatusDto } from "../../link/status-projection";
|
|
9
|
+
import { attachLinkPeers, projectLinkStatus, type LinkStatusDto } from "../../link/status-projection";
|
|
10
|
+
import { homeLinkChildParity } from "../../link/child-parity";
|
|
11
|
+
import { localLinkPeer } from "../../link/compatibility";
|
|
12
|
+
import { packageVersion } from "../../lib/package-version";
|
|
13
|
+
import { buildPeerFeatures } from "../../remote/features";
|
|
10
14
|
import { isLinkPort } from "../../link/ports";
|
|
11
15
|
import { joinHome, type ClientLinkJoinDeps } from "../../client/link-join";
|
|
12
16
|
import { clientLinkStatusIdentity } from "../../client/link-status";
|
|
@@ -268,6 +272,7 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
|
|
|
268
272
|
store, state.supervisor.status(), listenerStatus, ctx.config, readCompensation(),
|
|
269
273
|
ctx.config.runtimeRole === "client" ? clientLinkTunnelStatus() : null,
|
|
270
274
|
ctx.config.runtimeRole === "client" && ctx.config.client?.link ? clientLinkStatusIdentity(ctx.config.client.link.linkId) : null,
|
|
275
|
+
linkId => homeLinkChildParity.get(linkId),
|
|
271
276
|
);
|
|
272
277
|
for (const link of dto.links) {
|
|
273
278
|
const failure = state.compensationFailures?.get(link.id);
|
|
@@ -277,6 +282,12 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
|
|
|
277
282
|
const failure = state.compensationFailures?.get(store.links[0].id);
|
|
278
283
|
if (failure) Object.assign(dto.child, { state: "failed" as const, since: failure.since, reason: failure.reason });
|
|
279
284
|
}
|
|
285
|
+
// Opt-in: an older `ocx link status` validates the document key by key and would reject the
|
|
286
|
+
// added fields, so only a caller that asks for compatibility gets `local` and `peer`.
|
|
287
|
+
if (url.searchParams.get("compatibility") === "1") {
|
|
288
|
+
attachLinkPeers(dto, localLinkPeer(packageVersion("0.0.0"), buildPeerFeatures()),
|
|
289
|
+
linkId => state.listener.peerObservation?.(linkId) ?? { observed: null, refused: null });
|
|
290
|
+
}
|
|
280
291
|
// A dashboard session also learns why it cannot join as a Child. The admin-token answer stays
|
|
281
292
|
// the exact K16 document that `ocx link status` validates key by key.
|
|
282
293
|
const denial = ctx.principal === "gui-session" ? joinDenied(ctx) : null;
|
|
@@ -374,7 +374,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
374
374
|
// Without a flow id this cancels the provider's newest in-flight login, which is what the
|
|
375
375
|
// single-user GUI has always meant by "Cancel". A host running concurrent code logins for
|
|
376
376
|
// several users passes the flowId it was given at /api/oauth/login.
|
|
377
|
-
const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined
|
|
377
|
+
const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined,
|
|
378
|
+
{ dismissSettledFailure: true });
|
|
378
379
|
return jsonResponse({ ok: true, cancelled });
|
|
379
380
|
}
|
|
380
381
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/quota-pool`: the `ocx.quota-pool/1` read GOAT consumes (see src/quota-pool/).
|
|
3
|
+
*
|
|
4
|
+
* Same management authorization as the neighbouring reads; a Remote Link Child reaches it through
|
|
5
|
+
* the read-only link relay (`LINK_MANAGEMENT_READ_PATHS`) and a Hub client through its hub relay.
|
|
6
|
+
* The read is always passive: there is no refresh form, so nothing here can make the Home probe.
|
|
7
|
+
*/
|
|
8
|
+
import type { ManagementContext } from "./context";
|
|
9
|
+
import { jsonResponse } from "../auth-cors";
|
|
10
|
+
|
|
11
|
+
export async function handleQuotaPoolRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
12
|
+
const { req, url, config } = ctx;
|
|
13
|
+
// Any query (including a stray refresh=1) is ignored: the read has no active form.
|
|
14
|
+
if (url.pathname === "/api/quota-pool" && req.method === "GET") {
|
|
15
|
+
const { readQuotaPool } = await import("../../quota-pool/assemble");
|
|
16
|
+
const result = await readQuotaPool(config);
|
|
17
|
+
const response = result.ok
|
|
18
|
+
? jsonResponse(result.pool, 200, req, config)
|
|
19
|
+
: jsonResponse({ error: result.error }, 503, req, config);
|
|
20
|
+
response.headers.set("cache-control", "no-store");
|
|
21
|
+
return response;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|