@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/connect.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { hostname } from "node:os";
|
|
9
9
|
import { atomicWriteFile, loadConfig, withConfigMutationLockSync } from "../config";
|
|
10
10
|
import { claudeDesktopIntegrationEnabledNow } from "../codex/desired-state";
|
|
11
|
+
import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start";
|
|
11
12
|
import {
|
|
12
13
|
inspectRemoteDesktopStore, readDesktopDisconnectReceipt, writeDesktopDisconnectReceipt,
|
|
13
14
|
replaceRemoteDesktopCredential, restoreRemoteDesktopStore, finishRemoteDesktopCleanup,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
injectCodexConfig,
|
|
23
24
|
currentExternalCodexModelProvider,
|
|
24
25
|
isCodexRoutingInjected,
|
|
26
|
+
standaloneCodexRoutingTarget,
|
|
25
27
|
type CodexRoutingTarget,
|
|
26
28
|
} from "../codex/inject";
|
|
27
29
|
import {
|
|
@@ -44,6 +46,7 @@ import {
|
|
|
44
46
|
import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download";
|
|
45
47
|
import type {
|
|
46
48
|
OcxClientConnectionConfig,
|
|
49
|
+
OcxConfig,
|
|
47
50
|
OcxConnectedClientId,
|
|
48
51
|
} from "../types";
|
|
49
52
|
import {
|
|
@@ -169,20 +172,31 @@ function catalogMatchesFingerprint(body: string, fingerprint: string | undefined
|
|
|
169
172
|
return createHash("sha256").update(body).digest("base64url") === fingerprint;
|
|
170
173
|
}
|
|
171
174
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
175
|
+
/**
|
|
176
|
+
* Codex routing for a connected client. A hub client points Codex at the hub with the admission
|
|
177
|
+
* token in `env_key`. A link Child (`localPort` given) keeps exactly the standalone loopback form,
|
|
178
|
+
* root `openai_base_url = "http://127.0.0.1:<port>/v1"` with no provider table and no `env_key`:
|
|
179
|
+
* its own listener relays to the Home and attaches the link key itself, so a GUI-launched Codex
|
|
180
|
+
* that never saw the key's environment variable still works, and joining changes no Codex bytes.
|
|
181
|
+
*/
|
|
182
|
+
export function routingTarget(
|
|
183
|
+
serverUrl: string,
|
|
184
|
+
localPort?: number,
|
|
185
|
+
config?: Pick<OcxConfig, "codexDesktopAuthless" | "codexClientCompaction">,
|
|
186
|
+
): CodexRoutingTarget & { link?: true } {
|
|
187
|
+
if (localPort === undefined) {
|
|
188
|
+
return { baseUrl: `${serverUrl}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" };
|
|
189
|
+
}
|
|
190
|
+
if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
|
|
191
|
+
throw new Error("link mode requires a valid local config port");
|
|
192
|
+
}
|
|
181
193
|
return {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
194
|
+
...standaloneCodexRoutingTarget(localPort, {
|
|
195
|
+
hostname: "127.0.0.1",
|
|
196
|
+
codexDesktopAuthless: config?.codexDesktopAuthless,
|
|
197
|
+
codexClientCompaction: config?.codexClientCompaction,
|
|
198
|
+
}),
|
|
199
|
+
link: true as const,
|
|
186
200
|
};
|
|
187
201
|
}
|
|
188
202
|
|
|
@@ -538,6 +552,9 @@ export async function connectClient(
|
|
|
538
552
|
let writtenCatalogFingerprint: string | null = null;
|
|
539
553
|
let injectionCommitted = false;
|
|
540
554
|
let committed = false;
|
|
555
|
+
// Before any catalog, journal, client-state or credential write: `CODEX_HOME` is shared with the
|
|
556
|
+
// live proxy a sibling instance runs beside, and connecting re-points it (`sibling-start.ts`).
|
|
557
|
+
if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage());
|
|
541
558
|
try {
|
|
542
559
|
linkMode = (options.transport ?? "hub") === "link";
|
|
543
560
|
if (linkMode) {
|
|
@@ -655,7 +672,7 @@ export async function connectClient(
|
|
|
655
672
|
}), deps.lifecycleLockDeps);
|
|
656
673
|
|
|
657
674
|
const config = earlyConfig ?? loadConfig();
|
|
658
|
-
const target = routingTarget(serverUrl, linkMode ? config.port : undefined);
|
|
675
|
+
const target = routingTarget(serverUrl, linkMode ? config.port : undefined, config);
|
|
659
676
|
const injectConfig = { ...config, syncResumeHistory: false };
|
|
660
677
|
const preflight = await injectCodexConfig(config.port, injectConfig, {
|
|
661
678
|
validateOnly: true,
|
|
@@ -750,6 +767,7 @@ export async function syncConnectedClient(
|
|
|
750
767
|
_options: { restartCodex?: boolean } = {},
|
|
751
768
|
deps: ClientConnectDeps = {},
|
|
752
769
|
): Promise<{ catalogWritten: boolean; cacheSynced: boolean; injected: boolean; stale: boolean }> {
|
|
770
|
+
if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage());
|
|
753
771
|
const initial = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
|
|
754
772
|
assertNoClientDisconnectPending();
|
|
755
773
|
const state = readClientConnectionState();
|
|
@@ -796,7 +814,7 @@ export async function syncConnectedClient(
|
|
|
796
814
|
if (next.selectedClients.includes("codex")) {
|
|
797
815
|
const config = loadConfig();
|
|
798
816
|
const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, {
|
|
799
|
-
routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined), catalogPath: DEFAULT_CATALOG_PATH,
|
|
817
|
+
routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined, config), catalogPath: DEFAULT_CATALOG_PATH,
|
|
800
818
|
journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, beforeClientWrite,
|
|
801
819
|
});
|
|
802
820
|
if (!result.success || result.status === "skipped") throw new Error(result.message);
|
|
@@ -891,6 +909,7 @@ export async function disconnectClient(
|
|
|
891
909
|
desktopRestoration?: "owned_projection" | "standard_fallback" | "selection_preserved";
|
|
892
910
|
restartRequired: boolean;
|
|
893
911
|
}> {
|
|
912
|
+
if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage());
|
|
894
913
|
const keepCatalog = options.keepCatalog === true;
|
|
895
914
|
const prepared = withClientLifecycleSync(held => withConfigMutationLockSync(() => {
|
|
896
915
|
const read = readDesktopDisconnectReceipt();
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Server } from "bun";
|
|
2
|
+
import { formatErrorResponse } from "../bridge";
|
|
3
|
+
import { readServiceApiTokenState, type ServiceApiTokenState } from "../lib/service-secrets";
|
|
4
|
+
import { linkRouteAllowed } from "../link/routes";
|
|
5
|
+
import { isAllowedRequestOrigin, type RequestPolicyView } from "../server/auth-cors";
|
|
6
|
+
import { relayLinkDataRequest, type LinkRelayDeps } from "./link-relay";
|
|
7
|
+
|
|
8
|
+
/** How often an unavailable link key is looked for again. A valid key is never re-read. */
|
|
9
|
+
export const LINK_KEY_RETRY_MS = 1_000;
|
|
10
|
+
|
|
11
|
+
export interface LinkKeySourceDeps {
|
|
12
|
+
readToken?: () => ServiceApiTokenState;
|
|
13
|
+
now?: () => number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The link key for relayed requests, read from the service token file once and then held in
|
|
18
|
+
* memory, so no data-plane request touches the disk. The file must still hold the key this
|
|
19
|
+
* connection committed (`tokenFingerprint`); anything else yields null. Only while the key is
|
|
20
|
+
* unavailable is the file read again, at most once per `LINK_KEY_RETRY_MS`. The key is never
|
|
21
|
+
* logged, returned in a response, or written anywhere.
|
|
22
|
+
*/
|
|
23
|
+
export function createLinkKeySource(expectedFingerprint: string, deps: LinkKeySourceDeps = {}): () => string | null {
|
|
24
|
+
const readToken = deps.readToken ?? readServiceApiTokenState;
|
|
25
|
+
const now = deps.now ?? Date.now;
|
|
26
|
+
let key: string | null = null;
|
|
27
|
+
let lastRead = 0;
|
|
28
|
+
const load = (): void => {
|
|
29
|
+
lastRead = now();
|
|
30
|
+
try {
|
|
31
|
+
const state = readToken();
|
|
32
|
+
key = state.kind === "present" && state.fingerprint === expectedFingerprint ? state.token : null;
|
|
33
|
+
} catch {
|
|
34
|
+
key = null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
load();
|
|
38
|
+
return () => {
|
|
39
|
+
if (key === null && now() - lastRead >= LINK_KEY_RETRY_MS) load();
|
|
40
|
+
return key;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LinkIngress {
|
|
45
|
+
tunnelPort: number;
|
|
46
|
+
/** The listener's loopback policy: the standalone Host and Origin anti-rebinding gate. */
|
|
47
|
+
policy: RequestPolicyView;
|
|
48
|
+
linkKey: () => string | null;
|
|
49
|
+
relay?: LinkRelayDeps;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const RESPONSES_WEBSOCKET_DISABLED = "Responses WebSocket transport is disabled; use HTTP";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Lift Bun's per-request idle timer for a relayed request, as the standalone does on its data
|
|
56
|
+
* routes (`disableResponsesRequestTimeout`). Without it the listener's idle limit cuts a quiet
|
|
57
|
+
* stretch of a long generation: an SSE gap between events, or a Home that holds a turn after
|
|
58
|
+
* its headers. The relay bounds the wait itself (header deadline, SSE idle limit, caller abort).
|
|
59
|
+
* It is a local call rather than the standalone helper's import, which would load the Responses
|
|
60
|
+
* WebSocket upstream modules into every Child.
|
|
61
|
+
*/
|
|
62
|
+
function liftRequestIdleTimer(req: Request, server: Pick<Server<unknown>, "timeout"> | undefined): void {
|
|
63
|
+
if (!server) return;
|
|
64
|
+
try { server.timeout(req, 0); } catch { /* the request may already be closed */ }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The link-mode data plane of the client machine listener. It answers only the requests it
|
|
69
|
+
* owns and returns null for everything else, which the listener handles as before.
|
|
70
|
+
*
|
|
71
|
+
* - A `/v1/responses` WebSocket upgrade gets 426, the answer codex-rs turns into an HTTP
|
|
72
|
+
* fallback; the link never carries a WebSocket.
|
|
73
|
+
* - Every relayed route passes the standalone loopback Host/Origin gate before any upstream
|
|
74
|
+
* fetch, so a rebinding or cross-site browser page cannot reach the Home through this port.
|
|
75
|
+
* - Without the committed link key nothing is fetched, and the caller gets
|
|
76
|
+
* `503 link_credential_unavailable`.
|
|
77
|
+
* - `/readyz` stays local: it reports this listener, not the tunnel.
|
|
78
|
+
* - A relayed request is exempt from the listener's idle limit, like a standalone data route.
|
|
79
|
+
*/
|
|
80
|
+
export function handleLinkIngress(
|
|
81
|
+
req: Request,
|
|
82
|
+
url: URL,
|
|
83
|
+
ingress: LinkIngress,
|
|
84
|
+
server?: Pick<Server<unknown>, "timeout">,
|
|
85
|
+
): Response | Promise<Response> | null {
|
|
86
|
+
const upgrade = req.headers.get("upgrade");
|
|
87
|
+
if (upgrade !== null) {
|
|
88
|
+
if (url.pathname !== "/v1/responses" || upgrade.trim().toLowerCase() !== "websocket") return null;
|
|
89
|
+
if (!isAllowedRequestOrigin(req, ingress.policy)) {
|
|
90
|
+
return formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin");
|
|
91
|
+
}
|
|
92
|
+
return formatErrorResponse(426, "upgrade_required", RESPONSES_WEBSOCKET_DISABLED);
|
|
93
|
+
}
|
|
94
|
+
if (url.pathname === "/readyz" || !linkRouteAllowed(url, req)) return null;
|
|
95
|
+
if (!isAllowedRequestOrigin(req, ingress.policy)) {
|
|
96
|
+
return formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked");
|
|
97
|
+
}
|
|
98
|
+
const admissionKey = ingress.linkKey();
|
|
99
|
+
if (!admissionKey) return Response.json({ error: "link_credential_unavailable" }, { status: 503 });
|
|
100
|
+
liftRequestIdleTimer(req, server);
|
|
101
|
+
return relayLinkDataRequest(req, { tunnelPort: ingress.tunnelPort, admissionKey }, ingress.relay);
|
|
102
|
+
}
|
package/src/client/link-join.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
|
-
import {
|
|
4
|
-
import { isLinkPort } from "../link/ports";
|
|
5
|
-
import { buildExecArgv } from "../link/ssh-argv";
|
|
6
|
-
import type
|
|
3
|
+
import { isPortAvailable } from "../server/ports";
|
|
4
|
+
import { isLinkPort, JOIN_TUNNEL_PORT_MAX, JOIN_TUNNEL_PORT_MIN } from "../link/ports";
|
|
5
|
+
import { buildExecArgv, REMOTE_COMMAND_NOT_FOUND, remoteOcxArgv } from "../link/ssh-argv";
|
|
6
|
+
import { sshFailureHint, sshRunnerErrorHint, type SshRunner, type SshRunResult } from "../link/ssh-runner";
|
|
7
7
|
import { connectClient, type ClientConnectDeps } from "./connect";
|
|
8
8
|
import {
|
|
9
9
|
clearClientLinkState,
|
|
@@ -24,6 +24,7 @@ const JOIN_TUNNEL_READY_TIMEOUT_MS = 15_000;
|
|
|
24
24
|
const JOIN_TUNNEL_POLL_MS = 100;
|
|
25
25
|
const JOIN_REVOKE_TIMEOUT_MS = 30_000;
|
|
26
26
|
const JOIN_CONFIRM_TTL_MS = 5 * 60_000;
|
|
27
|
+
const JOIN_PORT_ATTEMPTS = 32;
|
|
27
28
|
const LINK_ID = /^lnk_[0-9a-f]{16}$/;
|
|
28
29
|
const API_KEY_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
29
30
|
const DATA_KEY = /^ocx_data_[0-9a-f]{40}$/;
|
|
@@ -40,6 +41,7 @@ export type JoinFailureCode =
|
|
|
40
41
|
| "host_confirmation_expired"
|
|
41
42
|
| "join_port_failed"
|
|
42
43
|
| "join_issue_failed"
|
|
44
|
+
| "remote_ocx_missing"
|
|
43
45
|
| "join_tunnel_failed"
|
|
44
46
|
| "admission_failed"
|
|
45
47
|
| "join_connect_failed"
|
|
@@ -47,7 +49,8 @@ export type JoinFailureCode =
|
|
|
47
49
|
| "join_restart_failed";
|
|
48
50
|
|
|
49
51
|
export class ClientLinkJoinError extends Error {
|
|
50
|
-
|
|
52
|
+
/** `hint` is a bounded line from ssh stderr or the ssh runner's own failure, for the dashboard; it is not part of the message. */
|
|
53
|
+
constructor(readonly code: JoinFailureCode, readonly linkId?: string, readonly hint?: string) {
|
|
51
54
|
super(linkId ? `${code}: ${linkId}` : code);
|
|
52
55
|
this.name = "ClientLinkJoinError";
|
|
53
56
|
}
|
|
@@ -118,6 +121,24 @@ function parseIssuedLink(stdout: string): IssuedLink | null {
|
|
|
118
121
|
};
|
|
119
122
|
}
|
|
120
123
|
|
|
124
|
+
/**
|
|
125
|
+
* A free loopback port in the join range (`JOIN_TUNNEL_PORT_MIN`-`JOIN_TUNNEL_PORT_MAX`), tried at
|
|
126
|
+
* random so a fixed-port service on this computer is not hit every time.
|
|
127
|
+
*/
|
|
128
|
+
export async function chooseJoinTunnelPort(deps: {
|
|
129
|
+
isAvailable?: (port: number) => Promise<boolean>;
|
|
130
|
+
random?: () => number;
|
|
131
|
+
} = {}): Promise<number> {
|
|
132
|
+
const isAvailable = deps.isAvailable ?? (port => isPortAvailable(port, "127.0.0.1"));
|
|
133
|
+
const random = deps.random ?? Math.random;
|
|
134
|
+
const span = JOIN_TUNNEL_PORT_MAX - JOIN_TUNNEL_PORT_MIN + 1;
|
|
135
|
+
for (let attempt = 0; attempt < JOIN_PORT_ATTEMPTS; attempt += 1) {
|
|
136
|
+
const port = JOIN_TUNNEL_PORT_MIN + Math.min(span - 1, Math.floor(random() * span));
|
|
137
|
+
if (await isAvailable(port)) return port;
|
|
138
|
+
}
|
|
139
|
+
throw new Error("no free port in the join tunnel range");
|
|
140
|
+
}
|
|
141
|
+
|
|
121
142
|
function localAlias(deps: ClientLinkJoinDeps): string {
|
|
122
143
|
const raw = (deps.hostname ?? hostname)().trim();
|
|
123
144
|
const normalized = raw.replace(/[^A-Za-z0-9_\.\-]/g, "-").replace(/^-+/, "").slice(0, 253);
|
|
@@ -148,7 +169,7 @@ async function revokeIssuedLink(deps: ClientLinkJoinDeps, linkId: string, alias
|
|
|
148
169
|
const result = await deps.runner.run(
|
|
149
170
|
buildExecArgv({
|
|
150
171
|
alias,
|
|
151
|
-
argv: ["
|
|
172
|
+
argv: remoteOcxArgv(["link", "revoke", "--link-id", linkId]),
|
|
152
173
|
knownHostsFile: deps.knownHostsFile,
|
|
153
174
|
}),
|
|
154
175
|
{ timeoutMs: JOIN_REVOKE_TIMEOUT_MS },
|
|
@@ -240,7 +261,7 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string
|
|
|
240
261
|
await compensateStaleSidecar(deps);
|
|
241
262
|
let tunnelPort: number;
|
|
242
263
|
try {
|
|
243
|
-
tunnelPort = await (deps.choosePort ?? (() =>
|
|
264
|
+
tunnelPort = await (deps.choosePort ?? (() => chooseJoinTunnelPort()))();
|
|
244
265
|
if (!isLinkPort(tunnelPort)) throw new Error("invalid link port");
|
|
245
266
|
} catch (error) {
|
|
246
267
|
void error;
|
|
@@ -248,24 +269,24 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string
|
|
|
248
269
|
}
|
|
249
270
|
|
|
250
271
|
const thisAlias = localAlias(deps);
|
|
251
|
-
let
|
|
272
|
+
let result: SshRunResult;
|
|
252
273
|
try {
|
|
253
|
-
|
|
274
|
+
result = await deps.runner.run(
|
|
254
275
|
buildExecArgv({
|
|
255
276
|
alias: input.alias,
|
|
256
|
-
argv: ["
|
|
277
|
+
argv: remoteOcxArgv(["link", "issue", "--alias", thisAlias, "--tunnel-port", String(tunnelPort), "--json"]),
|
|
257
278
|
knownHostsFile: deps.knownHostsFile,
|
|
258
279
|
}),
|
|
259
280
|
{ timeoutMs: JOIN_REVOKE_TIMEOUT_MS },
|
|
260
281
|
);
|
|
261
|
-
if (result.code !== 0) throw new Error("issue failed");
|
|
262
|
-
const parsed = parseIssuedLink(result.stdout);
|
|
263
|
-
if (!parsed) throw new Error("invalid issue response");
|
|
264
|
-
issued = parsed;
|
|
265
282
|
} catch (error) {
|
|
266
|
-
|
|
267
|
-
throw new ClientLinkJoinError("join_issue_failed");
|
|
283
|
+
throw new ClientLinkJoinError("join_issue_failed", undefined, sshRunnerErrorHint(error));
|
|
268
284
|
}
|
|
285
|
+
// Hints come from stderr only: a successful issue prints the new data key on stdout.
|
|
286
|
+
if (result.code === REMOTE_COMMAND_NOT_FOUND) throw new ClientLinkJoinError("remote_ocx_missing", undefined, sshFailureHint(result.stderr));
|
|
287
|
+
const parsed = result.code === 0 ? parseIssuedLink(result.stdout) : null;
|
|
288
|
+
if (!parsed) throw new ClientLinkJoinError("join_issue_failed", undefined, result.code === 0 ? undefined : sshFailureHint(result.stderr));
|
|
289
|
+
const issued: IssuedLink = parsed;
|
|
269
290
|
|
|
270
291
|
let tunnel: ClientLinkTunnelHandle | null = null;
|
|
271
292
|
try {
|