@bitkyc08/opencodex 2.65.0 → 2.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/gui/dist/assets/App-DyHlFeNB.js +51 -0
- package/gui/dist/assets/App-gkoJIEOV.css +1 -0
- package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-CI8cVKWu.js} +1 -1
- package/gui/dist/assets/index-BiwnVAUm.js +86 -0
- package/gui/dist/assets/index-ComVsdSk.css +1 -0
- package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-_WyYzbMt.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -2
- package/src/adapters/anthropic/beta-allowlist.ts +80 -0
- package/src/adapters/anthropic/passthrough.ts +221 -0
- package/src/adapters/anthropic-image-codec.ts +56 -19
- package/src/adapters/anthropic-image-normalize.ts +20 -10
- package/src/adapters/anthropic.ts +55 -24
- package/src/adapters/coding-agent/protocol.ts +29 -19
- package/src/adapters/cursor/live-transport.ts +18 -3
- package/src/adapters/cursor/native-exec-shell.ts +18 -63
- package/src/adapters/cursor/native-exec.ts +5 -2
- package/src/adapters/cursor/native-foreground-shell.ts +44 -0
- package/src/adapters/devin/cloud-direct/chat.ts +7 -1
- package/src/adapters/exec-tool-result-normalize.ts +4 -1
- package/src/adapters/google.ts +12 -0
- package/src/adapters/ollama-native.ts +32 -1
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
- package/src/adapters/openai-chat.ts +6 -4
- package/src/adapters/openai-responses/passthrough.ts +1 -0
- package/src/adapters/openai-responses/reasoning.ts +45 -1
- package/src/bridge/response-json.ts +10 -2
- package/src/chat/outbound.ts +104 -52
- package/src/claude/claude-code-block.ts +16 -0
- package/src/claude/context-windows.ts +52 -4
- package/src/claude/desktop-first-party.ts +25 -8
- package/src/claude/inbound.ts +22 -0
- package/src/claude/intercept/connect-proxy.ts +37 -4
- package/src/claude/intercept/proxy-auth.ts +166 -0
- package/src/claude/intercept/runtime.ts +21 -0
- package/src/claude/intercept/settings.ts +33 -12
- package/src/claude/outbound.ts +45 -27
- package/src/cli/account-extended.ts +47 -15
- package/src/cli/account-target.ts +115 -0
- package/src/cli/account.ts +20 -28
- package/src/cli/agent.ts +10 -1
- package/src/cli/api-protocols.ts +206 -0
- package/src/cli/capabilities.ts +86 -0
- package/src/cli/combo.ts +2 -2
- package/src/cli/connect.ts +127 -3
- package/src/cli/dispatch.ts +8 -0
- package/src/cli/doctor.ts +33 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/link.ts +288 -0
- package/src/cli/registry.ts +23 -0
- package/src/cli/runtime-api.ts +79 -0
- package/src/cli/system-command.ts +21 -2
- package/src/client/connect.ts +125 -23
- package/src/client/hub-relay.ts +12 -8
- package/src/client/link-join.ts +323 -0
- package/src/client/link-relay.ts +241 -0
- package/src/client/link-state.ts +110 -0
- package/src/client/link-teardown.ts +63 -0
- package/src/client/link-tunnel.ts +465 -0
- package/src/client/machine-listener.ts +14 -5
- package/src/client/runtime.ts +91 -42
- package/src/client/state.ts +4 -0
- package/src/codex/catalog/build-entries.ts +4 -0
- package/src/codex/catalog/derive-entry.ts +5 -0
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +4 -1
- package/src/codex/desktop-switches.ts +88 -8
- package/src/codex/inject/plan.ts +16 -3
- package/src/codex/inject.ts +3 -0
- package/src/codex/internal/catalog-writer.ts +13 -2
- package/src/combos/failover.ts +3 -2
- package/src/combos/index.ts +12 -0
- package/src/combos/jev.ts +646 -0
- package/src/combos/request.ts +5 -0
- package/src/combos/resolve.ts +17 -15
- package/src/combos/types.ts +47 -3
- package/src/config/atomic-write.ts +3 -0
- package/src/config/live-reconcile.ts +20 -4
- package/src/config/persist-unlocked.ts +20 -7
- package/src/config/schema/config-schema.ts +16 -0
- package/src/config/schema/leaf-validators.ts +38 -1
- package/src/generated/compatibility-version.json +416 -128
- package/src/integrations/omp-yaml-source.ts +109 -4
- package/src/lab/automation/orchestrator.ts +60 -4
- package/src/lab/events/limits.ts +1 -1
- package/src/lib/bounded-body.ts +55 -38
- package/src/lib/config-ownership.ts +11 -22
- package/src/lib/local-desktop-snapshot-capability.ts +56 -0
- package/src/lib/local-management-capability.ts +25 -3
- package/src/lib/optional-shutdown-hooks.ts +17 -0
- package/src/lib/state-store-registrations.ts +2 -2
- package/src/lib/windows-elevation.ts +62 -25
- package/src/lib/windows-secret-acl.ts +114 -23
- package/src/link/admission-wait.ts +73 -0
- package/src/link/compensation.ts +100 -0
- package/src/link/fingerprint.ts +21 -0
- package/src/link/paths.ts +19 -0
- package/src/link/ports.ts +12 -0
- package/src/link/routes.ts +32 -0
- package/src/link/ssh-argv.ts +170 -0
- package/src/link/ssh-config.ts +152 -0
- package/src/link/ssh-runner.ts +161 -0
- package/src/link/status-projection.ts +96 -0
- package/src/link/store.ts +139 -0
- package/src/link/supervisor.ts +404 -0
- package/src/link/tunnel-state.ts +91 -0
- package/src/protocols/baseline.ts +88 -0
- package/src/protocols/codecs/chat.ts +17 -0
- package/src/protocols/codecs/messages.ts +18 -0
- package/src/protocols/codecs/responses.ts +17 -0
- package/src/protocols/contract.ts +185 -0
- package/src/protocols/dto.ts +267 -0
- package/src/protocols/encoders/adapter-events.ts +876 -0
- package/src/protocols/encoders/chat.ts +243 -0
- package/src/protocols/encoders/messages.ts +462 -0
- package/src/protocols/envelope.ts +43 -0
- package/src/protocols/features.ts +295 -0
- package/src/protocols/guard.ts +33 -0
- package/src/protocols/opaque-state.ts +132 -0
- package/src/protocols/path.ts +39 -0
- package/src/protocols/plan-snapshot.ts +226 -0
- package/src/protocols/plan.ts +192 -0
- package/src/protocols/provider-summary.ts +101 -0
- package/src/protocols/settings.ts +118 -0
- package/src/protocols/shadow-plan.ts +36 -0
- package/src/protocols/shadow.ts +62 -0
- package/src/protocols/trace.ts +335 -0
- package/src/providers/model-rename-migration.ts +3 -3
- package/src/providers/registry/entries-core.ts +6 -1
- package/src/providers/registry/entries-extended.ts +15 -0
- package/src/providers/registry/model-ids.ts +1 -0
- package/src/providers/registry/types.ts +6 -0
- package/src/providers/registry.ts +1 -1
- package/src/remote-control/workspace-hub.ts +5 -4
- package/src/responses/apply-patch-envelope.ts +6 -1
- package/src/responses/code-mode-shell-input.ts +4 -2
- package/src/responses/freeform-wrapper-scan.ts +51 -24
- package/src/responses/progressive-freeform-input.ts +14 -20
- package/src/responses/spill-store.ts +187 -33
- package/src/responses/state/snapshot-select.ts +49 -0
- package/src/responses/state/spill-inspect.ts +46 -0
- package/src/responses/state/spill-queue.ts +16 -0
- package/src/responses/state.ts +51 -27
- package/src/server/audio-client.ts +8 -3
- package/src/server/audio-upstream.ts +9 -4
- package/src/server/auth-cors.ts +25 -8
- package/src/server/chat-completions.ts +71 -11
- package/src/server/chat-native-eligibility.ts +74 -0
- package/src/server/chat-native.ts +139 -74
- package/src/server/claude-messages.ts +140 -22
- package/src/server/hub-usage.ts +4 -3
- package/src/server/index/link-listener.ts +195 -0
- package/src/server/index/optional-listeners.ts +115 -0
- package/src/server/index/serve-options.ts +44 -10
- package/src/server/index.ts +10 -9
- package/src/server/inference/attempt.ts +51 -0
- package/src/server/inference/client-encoder-delivery.ts +235 -0
- package/src/server/inference/client-wire-log.ts +49 -0
- package/src/server/inference/client-wire.ts +69 -0
- package/src/server/inference/context.ts +15 -0
- package/src/server/inference/final-log.ts +37 -0
- package/src/server/local-desktop-snapshot-auth.ts +57 -0
- package/src/server/management/agent-settings-routes.ts +15 -13
- package/src/server/management/api-access.ts +11 -1
- package/src/server/management/config-routes.ts +3 -6
- package/src/server/management/context.ts +25 -0
- package/src/server/management/link-routes.ts +535 -0
- package/src/server/management/logs-usage-routes.ts +31 -1
- package/src/server/management/native-integration-routes.ts +6 -11
- package/src/server/management/oauth-account-routes.ts +42 -16
- package/src/server/management/protocol-routes.ts +159 -0
- package/src/server/management/protocol-settings-patch.ts +145 -0
- package/src/server/management/provider-patch-transaction.ts +61 -0
- package/src/server/management/provider-routes.ts +39 -18
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sidebar-routes.ts +8 -3
- package/src/server/management/system-routes.ts +18 -1
- package/src/server/management/usage-aggregate-cache.ts +206 -6
- package/src/server/management-api.ts +26 -1
- package/src/server/management-auth.ts +19 -5
- package/src/server/messages-native-eligibility.ts +161 -0
- package/src/server/messages-native-oauth.ts +149 -0
- package/src/server/messages-native.ts +790 -0
- package/src/server/relay.ts +9 -0
- package/src/server/request-log-filter.ts +92 -0
- package/src/server/request-log.ts +31 -70
- package/src/server/responses/adapter-delivery.ts +43 -16
- package/src/server/responses/agent-task-recovery.ts +90 -22
- package/src/server/responses/codex-ws-exchange.ts +4 -0
- package/src/server/responses/codex-ws-wire.ts +10 -1
- package/src/server/responses/core-combo-native.ts +323 -0
- package/src/server/responses/core-combo.ts +224 -13
- package/src/server/responses/core-options.ts +22 -0
- package/src/server/responses/core.ts +14 -26
- package/src/server/responses/request-sidecar-auth.ts +8 -3
- package/src/server/responses/run-turn-execution.ts +257 -63
- package/src/server/responses/sidecar-execution.ts +7 -4
- package/src/server/system-env.ts +7 -0
- package/src/service/diagnostics.ts +7 -1
- package/src/service/launchd.ts +26 -37
- package/src/service/windows-ops.ts +24 -20
- package/src/types/config.ts +58 -1
- package/src/types.ts +2 -0
- package/src/update/transactional-install.d.mts +0 -1
- package/src/update/transactional-install.mjs +17 -17
- package/src/usage/jev-stats.ts +495 -0
- package/src/usage/log.ts +24 -3
- package/src/web-search/run-turn-loop.ts +568 -0
- package/gui/dist/assets/App-8NMiZxT0.css +0 -1
- package/gui/dist/assets/App-DGLte6IR.js +0 -50
- package/gui/dist/assets/index--EWgGQvZ.css +0 -1
- package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
package/src/cli/runtime-api.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { runningProxyUpdateHeaders } from "../oauth/login-cli";
|
|
|
14
14
|
|
|
15
15
|
export type CliStdin = NodeJS.ReadableStream & { isTTY?: boolean; readableEnded?: boolean };
|
|
16
16
|
|
|
17
|
+
export const MAX_LINK_CREDENTIAL_BYTES = 4 * 1024;
|
|
18
|
+
|
|
17
19
|
export interface RuntimeApiDeps {
|
|
18
20
|
baseUrl?: string;
|
|
19
21
|
fetchImpl?: typeof fetch;
|
|
@@ -389,6 +391,81 @@ export async function readSecretLine(deps: RuntimeApiDeps, label: string): Promi
|
|
|
389
391
|
return line;
|
|
390
392
|
}
|
|
391
393
|
|
|
394
|
+
/** Read a bounded secret payload while keeping the original bytes available for zeroing. */
|
|
395
|
+
export async function readSecretBytes(
|
|
396
|
+
deps: RuntimeApiDeps,
|
|
397
|
+
label: string,
|
|
398
|
+
maxBytes = MAX_LINK_CREDENTIAL_BYTES,
|
|
399
|
+
): Promise<Uint8Array> {
|
|
400
|
+
const input: CliStdin = deps.stdinImpl ?? process.stdin;
|
|
401
|
+
const timeoutMs = deps.stdinTimeoutMs ?? 120_000;
|
|
402
|
+
if (input.readableEnded === true) throw new CliUsageError(`${label} input was empty`);
|
|
403
|
+
return await new Promise<Uint8Array>((resolve, reject) => {
|
|
404
|
+
const chunks: Uint8Array[] = [];
|
|
405
|
+
let total = 0;
|
|
406
|
+
let settled = false;
|
|
407
|
+
const cleanup = () => {
|
|
408
|
+
clearTimeout(timer);
|
|
409
|
+
input.removeListener("data", onData);
|
|
410
|
+
input.removeListener("end", onEnd);
|
|
411
|
+
input.removeListener("error", onError);
|
|
412
|
+
};
|
|
413
|
+
const wipeChunks = () => {
|
|
414
|
+
for (const chunk of chunks) chunk.fill(0);
|
|
415
|
+
};
|
|
416
|
+
const finish = (fn: () => void) => {
|
|
417
|
+
if (settled) return;
|
|
418
|
+
settled = true;
|
|
419
|
+
cleanup();
|
|
420
|
+
try { fn(); }
|
|
421
|
+
finally { wipeChunks(); }
|
|
422
|
+
};
|
|
423
|
+
const onData = (chunk: unknown) => {
|
|
424
|
+
const source = chunk instanceof Uint8Array ? chunk : undefined;
|
|
425
|
+
let bytes: Uint8Array | undefined;
|
|
426
|
+
let retained = false;
|
|
427
|
+
try {
|
|
428
|
+
bytes = typeof chunk === "string"
|
|
429
|
+
? new TextEncoder().encode(chunk)
|
|
430
|
+
: source
|
|
431
|
+
? new Uint8Array(source)
|
|
432
|
+
: new TextEncoder().encode(String(chunk));
|
|
433
|
+
total += bytes.byteLength;
|
|
434
|
+
if (total > maxBytes) {
|
|
435
|
+
finish(() => reject(new CliUsageError(`${label} exceeds ${maxBytes} bytes`)));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
chunks.push(bytes);
|
|
439
|
+
retained = true;
|
|
440
|
+
} finally {
|
|
441
|
+
source?.fill(0);
|
|
442
|
+
if (!retained) bytes?.fill(0);
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
const onEnd = () => finish(() => {
|
|
446
|
+
if (total === 0) {
|
|
447
|
+
reject(new CliUsageError(`${label} input was empty`));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const result = new Uint8Array(total);
|
|
451
|
+
let offset = 0;
|
|
452
|
+
for (const chunk of chunks) {
|
|
453
|
+
result.set(chunk, offset);
|
|
454
|
+
offset += chunk.byteLength;
|
|
455
|
+
}
|
|
456
|
+
resolve(result);
|
|
457
|
+
});
|
|
458
|
+
const onError = (error: Error) => finish(() => reject(error));
|
|
459
|
+
const timer = setTimeout(
|
|
460
|
+
() => finish(() => reject(new CliUsageError(`timed out waiting for ${label} on stdin`))),
|
|
461
|
+
timeoutMs,
|
|
462
|
+
);
|
|
463
|
+
input.on("data", onData);
|
|
464
|
+
input.on("end", onEnd);
|
|
465
|
+
input.on("error", onError);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
392
469
|
export function printData(value: unknown, wantsJson: boolean, lines?: string[]): void {
|
|
393
470
|
if (wantsJson || !lines) console.log(JSON.stringify(value, null, 2));
|
|
394
471
|
else for (const line of lines) console.log(line);
|
|
@@ -406,6 +483,8 @@ export function desktopSwitchApplyReason(reason: unknown): string {
|
|
|
406
483
|
if (reason === "not_requested") return "no desktop switch rewrite was requested";
|
|
407
484
|
if (reason === "proxy_not_running") return "the proxy is not running";
|
|
408
485
|
if (reason === "integration_disabled") return "Codex integration is disabled";
|
|
486
|
+
if (reason === "external_provider") return "an external model provider owns config.toml";
|
|
487
|
+
if (reason === "ownership_undetermined") return "config.toml ownership could not be determined";
|
|
409
488
|
if (reason === "write_lock_busy") return "the Codex config write lock is busy";
|
|
410
489
|
if (reason === "injection_refused") return "Codex config injection was refused";
|
|
411
490
|
return "the rewrite could not be completed";
|
|
@@ -57,6 +57,7 @@ function desktopSwitchInertReason(reason: unknown): string {
|
|
|
57
57
|
return "the stored setting is not effective in the current runtime configuration";
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
|
|
60
61
|
function settingsUpdateLines(
|
|
61
62
|
result: unknown,
|
|
62
63
|
changed: { desktopAuthless: boolean; clientCompaction: boolean },
|
|
@@ -68,8 +69,19 @@ function settingsUpdateLines(
|
|
|
68
69
|
const lines: string[] = [];
|
|
69
70
|
const appendSwitch = (key: string, label: string): boolean => {
|
|
70
71
|
const state = recordValue(switches[key]);
|
|
71
|
-
if (!state || typeof state.stored !== "boolean"
|
|
72
|
+
if (!state || typeof state.stored !== "boolean"
|
|
73
|
+
|| (typeof state.effective !== "boolean" && state.effective !== null)) return false;
|
|
72
74
|
lines.push(`${label}: stored ${state.stored ? "on" : "off"}.`);
|
|
75
|
+
if (state.effective === null) {
|
|
76
|
+
// `null` is reported for both withheld cases; the apply reason is the only place
|
|
77
|
+
// that still distinguishes them, so the line has to read it rather than claim
|
|
78
|
+
// external control over an ownership the server could not determine.
|
|
79
|
+
const withheld = recordValue(switches.apply)?.reason === "ownership_undetermined"
|
|
80
|
+
? "effective state could not be determined"
|
|
81
|
+
: "effective state is controlled by the external model provider";
|
|
82
|
+
lines.push(`${label}: ${withheld}.`);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
73
85
|
// The effective value is always stated, even when it matches. Printing it only on a
|
|
74
86
|
// mismatch would make silence ambiguous — the reader could not tell "the stored value is
|
|
75
87
|
// in force" from "this build does not report effective state", and that ambiguity is a
|
|
@@ -96,7 +108,14 @@ function settingsUpdateLines(
|
|
|
96
108
|
lines.push("Codex config: ~/.codex/config.toml was rewritten.");
|
|
97
109
|
} else {
|
|
98
110
|
const detail = typeof apply.detail === "string" && apply.detail.length > 0 ? ` Details: ${apply.detail}` : "";
|
|
99
|
-
|
|
111
|
+
const retry = apply.reason === "external_provider"
|
|
112
|
+
? ""
|
|
113
|
+
: apply.reason === "ownership_undetermined"
|
|
114
|
+
? " Resolve the reported config.toml read error, then inspect 'ocx system settings --json'."
|
|
115
|
+
: apply.reason === "integration_disabled"
|
|
116
|
+
? " Enable Codex integration before applying the stored settings."
|
|
117
|
+
: " Run 'ocx sync' to apply the stored settings.";
|
|
118
|
+
lines.push(`Codex config: ~/.codex/config.toml was not rewritten because ${desktopSwitchApplyReason(apply.reason)}.${detail}${retry}`);
|
|
100
119
|
}
|
|
101
120
|
lines.push(`Auth source: ${authSource.summary}`);
|
|
102
121
|
return lines;
|
package/src/client/connect.ts
CHANGED
|
@@ -66,11 +66,14 @@ import {
|
|
|
66
66
|
clearClientConnection,
|
|
67
67
|
commitClientConnection,
|
|
68
68
|
readClientConnectionState,
|
|
69
|
+
isLinkConnection,
|
|
69
70
|
markClientConnectPending, clearClientConnectPending, pendingClientConnectMayOwnToken,
|
|
70
71
|
assertNoClientDisconnectPending, assertClientConnectionUnchanged, sameClientConnectionOwner,
|
|
71
72
|
} from "./state";
|
|
72
73
|
import { assertClientCatalogCompatible, type CatalogCompatibilityDeps } from "./catalog-compatibility";
|
|
73
74
|
import { hubStateCachePath } from "./hub-state";
|
|
75
|
+
import { ClientLinkStateError, clearClientLinkState, readClientLinkState } from "./link-state";
|
|
76
|
+
import { isLinkPort } from "../link/ports";
|
|
74
77
|
|
|
75
78
|
class RotationRecoveryRequiredError extends Error {
|
|
76
79
|
constructor(message: string, options?: ErrorOptions) {
|
|
@@ -82,13 +85,21 @@ class RotationRecoveryRequiredError extends Error {
|
|
|
82
85
|
export interface ConnectOptions {
|
|
83
86
|
serverUrl: string;
|
|
84
87
|
managementUrl?: string;
|
|
85
|
-
credential: OneTimeConnectCredential;
|
|
88
|
+
credential: OneTimeConnectCredential | LinkClientCredential;
|
|
86
89
|
selectedClients: OcxConnectedClientId[];
|
|
87
90
|
managementTransport: "direct" | "relay";
|
|
91
|
+
transport?: "hub" | "link";
|
|
92
|
+
link?: { tunnelPort: number; linkId: string };
|
|
88
93
|
noSync?: boolean;
|
|
89
94
|
catalogTimeoutMs?: number;
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
export interface LinkClientCredential {
|
|
98
|
+
kind: "link";
|
|
99
|
+
apiKeyId: string;
|
|
100
|
+
key: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
92
103
|
export interface ClientConnectDeps {
|
|
93
104
|
fetchImpl?: typeof fetch;
|
|
94
105
|
now?: () => Date;
|
|
@@ -158,11 +169,20 @@ function catalogMatchesFingerprint(body: string, fingerprint: string | undefined
|
|
|
158
169
|
return createHash("sha256").update(body).digest("base64url") === fingerprint;
|
|
159
170
|
}
|
|
160
171
|
|
|
161
|
-
function routingTarget(serverUrl: string): CodexRoutingTarget {
|
|
172
|
+
export function routingTarget(serverUrl: string, localPort?: number): CodexRoutingTarget & { link?: true } {
|
|
173
|
+
const baseUrl = localPort === undefined
|
|
174
|
+
? `${serverUrl}/v1`
|
|
175
|
+
: (() => {
|
|
176
|
+
if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
|
|
177
|
+
throw new Error("link mode requires a valid local config port");
|
|
178
|
+
}
|
|
179
|
+
return `http://localhost:${localPort}/v1`;
|
|
180
|
+
})();
|
|
162
181
|
return {
|
|
163
|
-
baseUrl
|
|
182
|
+
baseUrl,
|
|
164
183
|
requiresAdmissionToken: true,
|
|
165
184
|
tokenEnv: "OPENCODEX_API_AUTH_TOKEN",
|
|
185
|
+
...(localPort === undefined ? {} : { link: true as const }),
|
|
166
186
|
};
|
|
167
187
|
}
|
|
168
188
|
|
|
@@ -180,6 +200,10 @@ function releaseCredential(credential: OneTimeConnectCredential): void {
|
|
|
180
200
|
credential.value.fill(0);
|
|
181
201
|
}
|
|
182
202
|
|
|
203
|
+
function releaseConnectCredential(credential: ConnectOptions["credential"]): void {
|
|
204
|
+
if (credential.kind !== "link") releaseCredential(credential);
|
|
205
|
+
}
|
|
206
|
+
|
|
183
207
|
async function rotationAuthority(
|
|
184
208
|
connection: OcxClientConnectionConfig,
|
|
185
209
|
options: RotateClientOptions,
|
|
@@ -349,6 +373,7 @@ export async function recoverPendingClientRotation(
|
|
|
349
373
|
assertNoClientDisconnectPending();
|
|
350
374
|
const state = readClientConnectionState();
|
|
351
375
|
if (state.kind !== "connected" || !state.value.pendingOperation) throw new Error("no pending client key rotation to recover");
|
|
376
|
+
if (isLinkConnection(state.value)) throw new Error("connect rotate is unavailable in link mode");
|
|
352
377
|
const authority = await rotationAuthority(state.value, options, deps);
|
|
353
378
|
assertClientConnectionUnchanged(state.value);
|
|
354
379
|
return recoverRotationWithAuthority(held, state.value, authority, deps);
|
|
@@ -381,6 +406,7 @@ async function rotateConnectedClientKeyHeld(
|
|
|
381
406
|
const state = readClientConnectionState();
|
|
382
407
|
if (state.kind !== "connected") throw new Error("connect rotate is available only while connected");
|
|
383
408
|
connection = state.value;
|
|
409
|
+
if (isLinkConnection(connection)) throw new Error("connect rotate is unavailable in link mode");
|
|
384
410
|
const current = readServiceApiTokenState();
|
|
385
411
|
if (current.kind !== "present") throw new Error("connected service token unavailable");
|
|
386
412
|
if (!connection.pendingOperation) {
|
|
@@ -501,6 +527,9 @@ export async function connectClient(
|
|
|
501
527
|
): Promise<OcxClientConnectionConfig> {
|
|
502
528
|
let serverUrl = "";
|
|
503
529
|
let managementUrl = "";
|
|
530
|
+
let linkAdmissionToken: string | null = null;
|
|
531
|
+
let linkMode = false;
|
|
532
|
+
let linkMetadata: { tunnelPort: number; linkId: string } | undefined;
|
|
504
533
|
let issued: IssuedClientKey | null = null;
|
|
505
534
|
let cleanupCredential: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession } | null = null;
|
|
506
535
|
let tokenFingerprint: string | null = null;
|
|
@@ -510,22 +539,68 @@ export async function connectClient(
|
|
|
510
539
|
let injectionCommitted = false;
|
|
511
540
|
let committed = false;
|
|
512
541
|
try {
|
|
513
|
-
|
|
514
|
-
if (
|
|
542
|
+
linkMode = (options.transport ?? "hub") === "link";
|
|
543
|
+
if (linkMode) {
|
|
544
|
+
if (options.credential.kind !== "link" || !options.link
|
|
545
|
+
|| !isLinkPort(options.link.tunnelPort)
|
|
546
|
+
|| !/^lnk_[0-9a-f]{16}$/.test(options.link.linkId)
|
|
547
|
+
|| !/^ocx_data_[0-9a-f]{40}$/.test(options.credential.key)
|
|
548
|
+
|| !options.credential.apiKeyId.trim() || options.credential.apiKeyId.length > 256) {
|
|
549
|
+
throw new Error("invalid link credential");
|
|
550
|
+
}
|
|
551
|
+
linkMetadata = { ...options.link };
|
|
552
|
+
serverUrl = `http://127.0.0.1:${linkMetadata.tunnelPort}`;
|
|
553
|
+
if ((options.managementUrl && options.managementUrl !== serverUrl) || options.managementTransport !== "direct") {
|
|
554
|
+
throw new Error("link mode requires direct management on the tunnel origin");
|
|
555
|
+
}
|
|
556
|
+
managementUrl = serverUrl;
|
|
557
|
+
} else {
|
|
558
|
+
if (options.credential.kind === "link" || options.link) throw new Error("link credential requires link transport");
|
|
559
|
+
serverUrl = normalizeHubOrigin(options.serverUrl);
|
|
560
|
+
if (options.managementUrl) managementUrl = normalizeHubOrigin(options.managementUrl);
|
|
561
|
+
}
|
|
515
562
|
if (options.selectedClients.length < 1 || new Set(options.selectedClients).size !== options.selectedClients.length) {
|
|
516
563
|
throw new Error("at least one unique connected client is required");
|
|
517
564
|
}
|
|
565
|
+
// Only link mode needs the local port before the hub is contacted; hub mode keeps reading config
|
|
566
|
+
// after the catalog download, so its stderr order (catalog refusal first) is unchanged.
|
|
567
|
+
const earlyConfig = linkMode ? loadConfig() : undefined;
|
|
568
|
+
if (earlyConfig && (!Number.isInteger(earlyConfig.port) || earlyConfig.port < 1 || earlyConfig.port > 65535)) {
|
|
569
|
+
throw new Error("link mode requires a valid local config port");
|
|
570
|
+
}
|
|
518
571
|
withClientLifecycleSync(() => withConfigMutationLockSync(() => {
|
|
519
572
|
assertConnectingState();
|
|
520
573
|
const externalProvider = currentExternalCodexModelProvider();
|
|
521
574
|
if (externalProvider) throw new Error("connect refused: an external Codex provider owns config.toml");
|
|
575
|
+
if (linkMode) {
|
|
576
|
+
const credential = options.credential;
|
|
577
|
+
if (credential.kind !== "link") throw new Error("invalid link credential");
|
|
578
|
+
const persisted = writeServiceApiTokenFile(credential.key);
|
|
579
|
+
pendingConnectFingerprint = persisted.fingerprint;
|
|
580
|
+
const reread = readServiceApiTokenState();
|
|
581
|
+
if (reread.kind !== "present" || reread.fingerprint !== persisted.fingerprint) {
|
|
582
|
+
throw new Error("invalid link credential");
|
|
583
|
+
}
|
|
584
|
+
markClientConnectPending(persisted.fingerprint);
|
|
585
|
+
linkAdmissionToken = reread.token;
|
|
586
|
+
}
|
|
522
587
|
}), deps.lifecycleLockDeps);
|
|
523
588
|
|
|
524
|
-
const
|
|
589
|
+
const upstreamFetch = deps.fetchImpl ?? fetch;
|
|
590
|
+
const readinessFetch = linkMode
|
|
591
|
+
? (async (input, init = {}) => {
|
|
592
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
593
|
+
if (!url.endsWith("/readyz")) return upstreamFetch(input, init);
|
|
594
|
+
const headers = new Headers(init.headers);
|
|
595
|
+
headers.set("x-opencodex-api-key", linkAdmissionToken!);
|
|
596
|
+
return upstreamFetch(input, { ...init, headers });
|
|
597
|
+
}) as typeof fetch
|
|
598
|
+
: upstreamFetch;
|
|
599
|
+
const ready = await fetchHubReady(serverUrl, { fetchImpl: readinessFetch });
|
|
525
600
|
if (ready.status !== "ready") throw new Error(`hub is not ready (${ready.status})`);
|
|
526
|
-
managementUrl = managementUrl || ready.metadata.managementUrl;
|
|
601
|
+
if (!linkMode) managementUrl = managementUrl || ready.metadata.managementUrl;
|
|
527
602
|
|
|
528
|
-
if (options.credential.kind === "pairing-grant") {
|
|
603
|
+
if (!linkMode && options.credential.kind === "pairing-grant") {
|
|
529
604
|
const session = await exchangeConnectPairingGrant(
|
|
530
605
|
managementUrl,
|
|
531
606
|
localGuiOrigin(),
|
|
@@ -533,23 +608,37 @@ export async function connectClient(
|
|
|
533
608
|
{ fetchImpl: deps.fetchImpl },
|
|
534
609
|
);
|
|
535
610
|
cleanupCredential = { kind: "gui-session", value: session };
|
|
536
|
-
} else {
|
|
611
|
+
} else if (!linkMode && options.credential.kind === "admin") {
|
|
537
612
|
cleanupCredential = { kind: "admin", value: options.credential.value };
|
|
538
613
|
}
|
|
539
|
-
|
|
614
|
+
if (!linkMode) {
|
|
615
|
+
issued = await issueClientKey(managementUrl, cleanupCredential!, clientKeyName(), { fetchImpl: deps.fetchImpl });
|
|
616
|
+
}
|
|
540
617
|
|
|
541
618
|
const initialFiles = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
|
|
542
|
-
assertConnectingState();
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
619
|
+
assertConnectingState(linkMode ? pendingConnectFingerprint! : undefined);
|
|
620
|
+
const persisted = linkMode
|
|
621
|
+
? (() => {
|
|
622
|
+
const current = readServiceApiTokenState();
|
|
623
|
+
if (current.kind !== "present") throw new Error("invalid link credential");
|
|
624
|
+
return { fingerprint: current.fingerprint };
|
|
625
|
+
})()
|
|
626
|
+
: writeServiceApiTokenFile(issued!.key);
|
|
627
|
+
const fingerprint = persisted.fingerprint;
|
|
628
|
+
if (!pendingConnectFingerprint) {
|
|
629
|
+
markClientConnectPending(fingerprint);
|
|
630
|
+
pendingConnectFingerprint = fingerprint;
|
|
631
|
+
}
|
|
632
|
+
return { prior: catalogSnapshot(), persisted };
|
|
547
633
|
}), deps.lifecycleLockDeps);
|
|
548
634
|
priorCatalog = initialFiles.prior;
|
|
549
635
|
const persisted = initialFiles.persisted;
|
|
550
636
|
tokenFingerprint = persisted.fingerprint;
|
|
551
637
|
|
|
552
|
-
const
|
|
638
|
+
const admissionToken = linkMode ? linkAdmissionToken : issued?.key;
|
|
639
|
+
if (!admissionToken) throw new Error("client admission credential unavailable");
|
|
640
|
+
const apiKeyId = linkMode ? (options.credential as LinkClientCredential).apiKeyId : issued!.id;
|
|
641
|
+
const catalog = await downloadClientCatalog(serverUrl, admissionToken, {
|
|
553
642
|
fetchImpl: deps.fetchImpl,
|
|
554
643
|
timeoutMs: options.catalogTimeoutMs,
|
|
555
644
|
});
|
|
@@ -565,14 +654,14 @@ export async function connectClient(
|
|
|
565
654
|
return sha256(catalog.body);
|
|
566
655
|
}), deps.lifecycleLockDeps);
|
|
567
656
|
|
|
568
|
-
const config = loadConfig();
|
|
569
|
-
const target = routingTarget(serverUrl);
|
|
657
|
+
const config = earlyConfig ?? loadConfig();
|
|
658
|
+
const target = routingTarget(serverUrl, linkMode ? config.port : undefined);
|
|
570
659
|
const injectConfig = { ...config, syncResumeHistory: false };
|
|
571
660
|
const preflight = await injectCodexConfig(config.port, injectConfig, {
|
|
572
661
|
validateOnly: true,
|
|
573
662
|
routingTarget: target,
|
|
574
663
|
catalogPath: DEFAULT_CATALOG_PATH,
|
|
575
|
-
journalOwner: { kind: "client", apiKeyId
|
|
664
|
+
journalOwner: { kind: "client", apiKeyId },
|
|
576
665
|
beforeClientWrite: () => assertConnectingState(persisted.fingerprint),
|
|
577
666
|
});
|
|
578
667
|
if (!preflight.success) throw new Error(preflight.message);
|
|
@@ -581,7 +670,7 @@ export async function connectClient(
|
|
|
581
670
|
const injected = await injectCodexConfig(config.port, injectConfig, {
|
|
582
671
|
routingTarget: target,
|
|
583
672
|
catalogPath: DEFAULT_CATALOG_PATH,
|
|
584
|
-
journalOwner: { kind: "client", apiKeyId
|
|
673
|
+
journalOwner: { kind: "client", apiKeyId },
|
|
585
674
|
beforeClientWrite: () => assertConnectingState(persisted.fingerprint),
|
|
586
675
|
});
|
|
587
676
|
if (!injected.success || injected.status === "skipped") throw new Error(injected.message);
|
|
@@ -596,7 +685,7 @@ export async function connectClient(
|
|
|
596
685
|
managementTransport: options.managementTransport,
|
|
597
686
|
selectedClients: [...options.selectedClients],
|
|
598
687
|
tokenEnv: "OPENCODEX_API_AUTH_TOKEN",
|
|
599
|
-
apiKeyId
|
|
688
|
+
apiKeyId,
|
|
600
689
|
tokenFingerprint: persisted.fingerprint,
|
|
601
690
|
protocolVersion: 1,
|
|
602
691
|
connectedAt: now,
|
|
@@ -606,6 +695,7 @@ export async function connectClient(
|
|
|
606
695
|
// back in the same run.
|
|
607
696
|
priorCatalog: priorCatalog.kind === "file" ? Buffer.from(priorCatalog.body, "utf8").toString("base64") : "",
|
|
608
697
|
catalogSyncedAt: now,
|
|
698
|
+
...(linkMode ? { transport: "link" as const, link: linkMetadata } : {}),
|
|
609
699
|
};
|
|
610
700
|
withClientLifecycleSync(() => withConfigMutationLockSync(() => {
|
|
611
701
|
assertConnectingState(persisted.fingerprint);
|
|
@@ -645,7 +735,7 @@ export async function connectClient(
|
|
|
645
735
|
];
|
|
646
736
|
throw new Error(details.length > 0 ? `${base}. ${details.join(" ")}` : base, { cause: error });
|
|
647
737
|
} finally {
|
|
648
|
-
|
|
738
|
+
releaseConnectCredential(options.credential);
|
|
649
739
|
cleanupCredential = null;
|
|
650
740
|
issued = null;
|
|
651
741
|
if (!committed) {
|
|
@@ -706,7 +796,7 @@ export async function syncConnectedClient(
|
|
|
706
796
|
if (next.selectedClients.includes("codex")) {
|
|
707
797
|
const config = loadConfig();
|
|
708
798
|
const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, {
|
|
709
|
-
routingTarget: routingTarget(next.serverUrl), catalogPath: DEFAULT_CATALOG_PATH,
|
|
799
|
+
routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined), catalogPath: DEFAULT_CATALOG_PATH,
|
|
710
800
|
journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, beforeClientWrite,
|
|
711
801
|
});
|
|
712
802
|
if (!result.success || result.status === "skipped") throw new Error(result.message);
|
|
@@ -902,6 +992,17 @@ export async function disconnectClient(
|
|
|
902
992
|
if (!disconnectAtLeast(receipt, "clearing_connection")) advance("clearing_connection");
|
|
903
993
|
if (clearClientConnection(receipt.owner) === "conflict") throw new Error("client_disconnect_owner_changed");
|
|
904
994
|
if (!disconnectAtLeast(receipt, "connection_cleared")) advance("connection_cleared");
|
|
995
|
+
if (connection?.transport === "link" && connection.link) {
|
|
996
|
+
// A corrupt sidecar is left in place: the connection is already cleared, the client runtime
|
|
997
|
+
// will not start a tunnel for a disconnected client, and the next join overwrites the file.
|
|
998
|
+
let sidecarLinkId: string | null = null;
|
|
999
|
+
try {
|
|
1000
|
+
sidecarLinkId = readClientLinkState()?.linkId ?? null;
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
if (!(error instanceof ClientLinkStateError)) throw error;
|
|
1003
|
+
}
|
|
1004
|
+
if (sidecarLinkId === connection.link.linkId) clearClientLinkState(connection.link.linkId);
|
|
1005
|
+
}
|
|
905
1006
|
removeHubStateCache();
|
|
906
1007
|
requireDesktopResult(finishRemoteDesktopCleanup(held, receipt.owner));
|
|
907
1008
|
if (receipt.phase !== "complete") advance("complete");
|
|
@@ -940,6 +1041,7 @@ export async function revokeConnectedClientKey(
|
|
|
940
1041
|
assertNoClientDisconnectPending();
|
|
941
1042
|
const state = readClientConnectionState();
|
|
942
1043
|
if (state.kind !== "connected" || state.value.pendingOperation) throw new Error("connect revoke requires a settled connection");
|
|
1044
|
+
if (isLinkConnection(state.value)) throw new Error("connect revoke is unavailable in link mode");
|
|
943
1045
|
await revokeClientKey(state.value.managementUrl, credential, state.value.apiKeyId, { fetchImpl: deps.fetchImpl });
|
|
944
1046
|
assertClientConnectionUnchanged(state.value);
|
|
945
1047
|
return { apiKeyId: state.value.apiKeyId };
|
package/src/client/hub-relay.ts
CHANGED
|
@@ -108,7 +108,7 @@ function relayDestination(suffix: string, target: HubRelayTarget, method: string
|
|
|
108
108
|
return destination;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
async function
|
|
111
|
+
export async function readBoundedRelayRequestBody(
|
|
112
112
|
stream: ReadableStream<Uint8Array> | null,
|
|
113
113
|
declared: string | null,
|
|
114
114
|
limit: number,
|
|
@@ -142,16 +142,20 @@ async function boundedBody(
|
|
|
142
142
|
return body;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
function
|
|
145
|
+
export function filterRelayHeaders(
|
|
146
|
+
source: Headers,
|
|
147
|
+
allowlist?: ReadonlySet<string>,
|
|
148
|
+
omitted: ReadonlySet<string> = new Set(),
|
|
149
|
+
): Headers {
|
|
146
150
|
const headers = new Headers();
|
|
147
151
|
for (const [name, value] of source) {
|
|
148
152
|
const normalized = name.toLowerCase();
|
|
149
|
-
if (allowlist.has(normalized) && !HOP_BY_HOP_HEADERS.has(normalized) && !omitted.has(normalized)) headers.append(name, value);
|
|
153
|
+
if ((!allowlist || allowlist.has(normalized)) && !HOP_BY_HOP_HEADERS.has(normalized) && !omitted.has(normalized)) headers.append(name, value);
|
|
150
154
|
}
|
|
151
155
|
return headers;
|
|
152
156
|
}
|
|
153
157
|
|
|
154
|
-
function headersWithinLimit(headers: Headers): boolean {
|
|
158
|
+
export function headersWithinLimit(headers: Headers): boolean {
|
|
155
159
|
let bytes = 0;
|
|
156
160
|
for (const [name, value] of headers) {
|
|
157
161
|
bytes += name.length + value.length + 4;
|
|
@@ -160,7 +164,7 @@ function headersWithinLimit(headers: Headers): boolean {
|
|
|
160
164
|
return true;
|
|
161
165
|
}
|
|
162
166
|
|
|
163
|
-
function boundedRelayResponseStream(
|
|
167
|
+
export function boundedRelayResponseStream(
|
|
164
168
|
body: ReadableStream<Uint8Array>,
|
|
165
169
|
limit: number,
|
|
166
170
|
signal: AbortSignal,
|
|
@@ -228,13 +232,13 @@ export async function relayHubManagementRequest(
|
|
|
228
232
|
try {
|
|
229
233
|
body = method === "GET" || method === "HEAD"
|
|
230
234
|
? null
|
|
231
|
-
: await
|
|
235
|
+
: await readBoundedRelayRequestBody(req.body, req.headers.get("content-length"), HUB_RELAY_REQUEST_BODY_MAX_BYTES);
|
|
232
236
|
} catch {
|
|
233
237
|
return relayError(413, "hub relay request body too large");
|
|
234
238
|
}
|
|
235
239
|
|
|
236
240
|
const stripped = stripMachineAuthHeaders(req.headers);
|
|
237
|
-
const headers =
|
|
241
|
+
const headers = filterRelayHeaders(stripped, REQUEST_HEADERS, requestHeaderValidation.connectionNamed);
|
|
238
242
|
if (!headersWithinLimit(headers)) return relayError(431, "hub relay request headers too large");
|
|
239
243
|
const browserOrigin = canonicalOrigin(target.browserOrigin);
|
|
240
244
|
const mutation = method !== "GET" && method !== "HEAD";
|
|
@@ -285,7 +289,7 @@ export async function relayHubManagementRequest(
|
|
|
285
289
|
}
|
|
286
290
|
|
|
287
291
|
const responseConnectionNamed = new Set((upstream.headers.get("connection") ?? "").split(",").map(value => value.trim().toLowerCase()).filter(Boolean));
|
|
288
|
-
const responseHeaders =
|
|
292
|
+
const responseHeaders = filterRelayHeaders(upstream.headers, RESPONSE_HEADERS, responseConnectionNamed);
|
|
289
293
|
if (!headersWithinLimit(responseHeaders)) {
|
|
290
294
|
cleanup();
|
|
291
295
|
try { await upstream.body?.cancel(); } catch { /* best effort */ }
|