@bitkyc08/opencodex 2.82.0 → 2.84.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-CZtqiGcw.js +56 -0
- package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
- package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/cursor/catalog.ts +2 -2
- package/src/adapters/cursor/effort-map.ts +1 -1
- package/src/adapters/devin/live-models.ts +2 -2
- package/src/adapters/google-truncation.ts +22 -0
- package/src/adapters/google.ts +7 -3
- package/src/adapters/ollama-native.ts +47 -15
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -0
- package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
- package/src/adapters/openai-responses/reasoning.ts +11 -1
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +16 -14
- package/src/bridge/errors.ts +26 -1
- package/src/bridge.ts +1 -1
- package/src/claude/context-windows.ts +57 -4
- package/src/claude/desktop-picker.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +44 -12
- package/src/cli/account.ts +41 -5
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-base.ts +13 -1
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/claude.ts +15 -6
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +16 -3
- package/src/cli/export-command.ts +14 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +13 -4
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +5 -4
- package/src/cli/resolve.ts +1 -1
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- package/src/clients/config-export/model-metadata.ts +16 -0
- package/src/clients/config-export/zcode-store.ts +46 -11
- package/src/clients/config-export/zcode.ts +3 -5
- package/src/clients/config-export.ts +47 -17
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/pool-mode-gate.ts +4 -5
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/auth-context.ts +8 -3
- package/src/codex/autostart-health.ts +3 -1
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/effort.ts +2 -2
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/native-models.ts +17 -3
- package/src/codex/catalog/provider-models.ts +25 -0
- package/src/codex/desktop-app/darwin.ts +1 -1
- package/src/codex/desktop-app/handoff.ts +1 -1
- package/src/codex/desktop-app/linux.ts +1 -1
- package/src/codex/desktop-app/lock.ts +1 -1
- package/src/codex/desktop-app/types.ts +1 -1
- package/src/codex/desktop-app/windows.ts +1 -1
- package/src/codex/desktop-app-restart.ts +1 -1
- package/src/codex/main-account.ts +6 -7
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-overlay.ts +8 -4
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +16 -0
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +43 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +2 -1
- package/src/config/paths.ts +3 -0
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +2 -0
- package/src/generated/compatibility-version.json +373 -217
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/inject.ts +245 -221
- package/src/grok/reset-coupon-ledger.ts +293 -57
- package/src/grok/reset-coupons.ts +15 -6
- package/src/integrations/cursor-detect.ts +1 -1
- package/src/integrations/mutation-plan.ts +13 -7
- package/src/integrations/registry.ts +45 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +14 -6
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/errors.ts +25 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/shadow-call.ts +44 -5
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +25 -16
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro.ts +48 -5
- package/src/oauth/meta-muse-device.ts +1 -1
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +3 -1
- package/src/plugins/loader.ts +11 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-fast.ts +1 -1
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/devin-cli-authmode-migration.ts +1 -1
- package/src/providers/devin-provider-merge-migration.ts +1 -1
- package/src/providers/github-copilot-auto.ts +169 -0
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-presets.ts +1 -1
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/muse-subscription-usage.ts +1 -1
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +20 -20
- package/src/providers/registry/entries-extended.ts +4 -1
- package/src/providers/registry/model-seeds.ts +20 -3
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/providers/xai-fast-model.ts +1 -1
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +7 -1
- package/src/server/auth-cors.ts +42 -0
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/inbound-body-admission.ts +24 -7
- package/src/server/index/link-listener.ts +30 -17
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +21 -2
- package/src/server/index.ts +1 -1
- package/src/server/lifecycle.ts +33 -5
- package/src/server/management/agent-settings-routes.ts +28 -10
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/grok-coupon-routes.ts +333 -57
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +92 -390
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +7 -5
- package/src/server/management/shared.ts +3 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native.ts +68 -42
- package/src/server/relay-eager.ts +4 -0
- package/src/server/relay.ts +18 -7
- package/src/server/request-log-failure-attribution.ts +3 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +243 -0
- package/src/server/request-log.ts +15 -118
- package/src/server/responses/adapter-continuation.ts +9 -1
- package/src/server/responses/adapter-dispatch.ts +293 -81
- package/src/server/responses/code-mode-collab-surface.ts +205 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +129 -50
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +14 -0
- package/src/server/responses/core-combo.ts +7 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-opaque-recovery.ts +5 -5
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core.ts +6 -6
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/empty-completion-guard.ts +18 -2
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/jev-model-invoke.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +4 -1
- package/src/server/responses/passthrough-dispatch.ts +130 -64
- package/src/server/responses/passthrough-execution.ts +3 -3
- package/src/server/responses/request-prepare.ts +6 -4
- package/src/server/responses/request-transport.ts +131 -13
- package/src/server/responses/run-turn-execution.ts +13 -5
- package/src/server/responses/sidecar-execution.ts +16 -2
- package/src/server/responses/spend-storage-error.ts +33 -0
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/startup-action-control.ts +22 -2
- package/src/server/startup-health-cache.ts +4 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +147 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +447 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +54 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/types/config.ts +5 -0
- package/src/types/request.ts +5 -0
- package/src/usage/expected-prices.ts +5 -1
- package/src/usage/log.ts +19 -1
- package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
- package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
- package/gui/dist/assets/index-D3FBCNiP.js +0 -94
package/src/client/hub-client.ts
CHANGED
|
@@ -485,7 +485,7 @@ export async function fetchHubUsage(
|
|
|
485
485
|
serverUrl: string,
|
|
486
486
|
admissionToken: string,
|
|
487
487
|
query: URLSearchParams,
|
|
488
|
-
options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
|
|
488
|
+
options: { timeoutMs?: number; fetchImpl?: typeof fetch; peer?: "hub" | "Home" } = {},
|
|
489
489
|
): Promise<HubUsageReport> {
|
|
490
490
|
const origin = normalizeHubOrigin(serverUrl);
|
|
491
491
|
if (!isHubTransportPermitted(origin)) {
|
|
@@ -498,9 +498,12 @@ export async function fetchHubUsage(
|
|
|
498
498
|
}, options.timeoutMs);
|
|
499
499
|
if (!response.ok) {
|
|
500
500
|
try { await response.body?.cancel(); } catch { /* best effort */ }
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
501
|
+
// A Remote Link Child reads the same route from its Home (#6957).
|
|
502
|
+
const peer = options.peer ?? "hub";
|
|
503
|
+
const Peer = peer === "Home" ? "Home" : "Hub";
|
|
504
|
+
const message = response.status === 404 ? `${Peer} does not support client usage; upgrade the ${peer}`
|
|
505
|
+
: response.status === 401 || response.status === 403 ? `${Peer} rejected this ${peer === "Home" ? "Child's link key" : "client's usage credential"}`
|
|
506
|
+
: `${Peer} usage request failed (${response.status})`;
|
|
504
507
|
throw new HubClientError(`hub_usage_http_${response.status}`, message, response.status);
|
|
505
508
|
}
|
|
506
509
|
if (!jsonCompatibleContentType(response)) {
|
|
@@ -527,16 +530,20 @@ export async function fetchHubUsage(
|
|
|
527
530
|
*/
|
|
528
531
|
export async function fetchHubState(
|
|
529
532
|
serverUrl: string,
|
|
530
|
-
admissionToken: string,
|
|
533
|
+
admissionToken: string | null,
|
|
531
534
|
options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
|
|
532
535
|
): Promise<HubStateDTO> {
|
|
533
536
|
const origin = normalizeHubOrigin(serverUrl);
|
|
534
537
|
const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/hub-state`, {
|
|
535
538
|
method: "GET",
|
|
536
|
-
headers: new Headers({ Accept: "application/json", "x-opencodex-api-key": admissionToken }),
|
|
539
|
+
headers: new Headers({ Accept: "application/json", ...(admissionToken === null ? {} : { "x-opencodex-api-key": admissionToken }) }),
|
|
537
540
|
}, options.timeoutMs, "headers");
|
|
538
541
|
if (response.status === 404) {
|
|
539
|
-
|
|
542
|
+
let body: unknown = null;
|
|
543
|
+
try { body = JSON.parse(await boundedText(response, 4096, { inactivityTimeoutMs: safeTimeout(options.timeoutMs) })); } catch { /* legacy/non-JSON 404 stays unsupported */ }
|
|
544
|
+
if (body && typeof body === "object" && !Array.isArray(body) && (body as { error?: { code?: unknown } }).error?.code === "hub_state_not_a_hub") {
|
|
545
|
+
throw new HubClientError("hub_state_not_a_hub", "the remote host does not publish hub state", 404);
|
|
546
|
+
}
|
|
540
547
|
throw new HubClientError("hub_state_unsupported", "Hub does not serve /v1/hub-state; upgrade the hub", 404);
|
|
541
548
|
}
|
|
542
549
|
if (!response.ok) {
|
package/src/client/hub-state.ts
CHANGED
|
@@ -124,37 +124,43 @@ export function writeCachedHubState(owner: HubStateOwner, state: HubStateDTO, fe
|
|
|
124
124
|
* banner reading `state unavailable (hub_state_http_507)` sends the reader looking for a client
|
|
125
125
|
* bug when the hub has in fact answered and said something.
|
|
126
126
|
*/
|
|
127
|
-
export function hubStateFailureReason(error: unknown): string {
|
|
127
|
+
export function hubStateFailureReason(error: unknown, peer: "hub" | "Home" = "hub"): string {
|
|
128
|
+
// A Remote Link Child reads the same route from its Home (#6957); the sentences name the peer
|
|
129
|
+
// the operator actually configured.
|
|
130
|
+
const holder = peer === "Home" ? "this Child's link key" : "this client's data key";
|
|
128
131
|
if (error instanceof HubClientError) {
|
|
129
132
|
switch (error.code) {
|
|
133
|
+
case "hub_state_not_a_hub":
|
|
134
|
+
return "the remote host does not publish hub state";
|
|
130
135
|
case "hub_state_unsupported":
|
|
131
|
-
return
|
|
136
|
+
return `this ${peer} is too old to report its state; upgrade the ${peer}`;
|
|
132
137
|
case "hub_state_unauthorized":
|
|
133
|
-
return
|
|
138
|
+
return `the ${peer} rejected ${holder}`;
|
|
134
139
|
case "hub_state_schema_invalid":
|
|
135
140
|
case "hub_state_invalid":
|
|
136
|
-
return
|
|
141
|
+
return `the ${peer} returned an unreadable hub-state document`;
|
|
137
142
|
case "hub_state_content_type_invalid":
|
|
138
143
|
// Usually a captive portal, a TLS-terminating proxy or an error page in front of the
|
|
139
144
|
// hub: the request reached SOMETHING, and that something is not the hub's API.
|
|
140
|
-
return
|
|
145
|
+
return `the ${peer}'s state response was not JSON`;
|
|
141
146
|
case "body_too_large":
|
|
142
|
-
return
|
|
147
|
+
return `the ${peer}'s state response exceeded the allowed size`;
|
|
143
148
|
case "unreachable":
|
|
144
|
-
return
|
|
149
|
+
return `the ${peer} is unreachable`;
|
|
145
150
|
case "redirect_refused":
|
|
146
|
-
return
|
|
151
|
+
return `the ${peer} redirected the state request`;
|
|
147
152
|
default: {
|
|
148
153
|
const status = error.code.startsWith("hub_state_http_")
|
|
149
154
|
? error.code.slice("hub_state_http_".length)
|
|
150
155
|
: null;
|
|
151
156
|
return status && /^\d+$/.test(status)
|
|
152
|
-
? `the
|
|
157
|
+
? `the ${peer} answered HTTP ${status} to the state request`
|
|
153
158
|
: error.code;
|
|
154
159
|
}
|
|
155
160
|
}
|
|
156
161
|
}
|
|
157
|
-
|
|
162
|
+
if (error instanceof Error && error.name === "LinkRelayAuthenticationError") return error.message;
|
|
163
|
+
return `the ${peer} state could not be read`;
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
export interface ResolveHubStateOptions {
|
|
@@ -167,6 +173,8 @@ export interface ResolveHubStateOptions {
|
|
|
167
173
|
* token for a different connection, and the operator acts on that difference.
|
|
168
174
|
*/
|
|
169
175
|
withheldTokenReason?: string;
|
|
176
|
+
/** Identity-checked local Child transport that strips credentials and owns Home admission. */
|
|
177
|
+
localRelayFetchImpl?: typeof fetch;
|
|
170
178
|
timeoutMs?: number;
|
|
171
179
|
fetchImpl?: typeof fetch;
|
|
172
180
|
now?: number;
|
|
@@ -174,6 +182,8 @@ export interface ResolveHubStateOptions {
|
|
|
174
182
|
allowNetwork?: boolean;
|
|
175
183
|
/** False skips the cache write, for read-only callers. */
|
|
176
184
|
persist?: boolean;
|
|
185
|
+
/** Which peer the sentences name: a hub, or a Remote Link Child's Home. */
|
|
186
|
+
peer?: "hub" | "Home";
|
|
177
187
|
}
|
|
178
188
|
|
|
179
189
|
function withAge(
|
|
@@ -201,20 +211,21 @@ export async function resolveHubState(options: ResolveHubStateOptions): Promise<
|
|
|
201
211
|
? withAge("cache", cached.state, cached.fetchedAt, now, reason)
|
|
202
212
|
: withAge("unavailable", null, undefined, now, reason);
|
|
203
213
|
};
|
|
204
|
-
if (!options.token) {
|
|
214
|
+
if (!options.token && !options.localRelayFetchImpl) {
|
|
205
215
|
return fromCache(options.withheldTokenReason ?? "this client has no usable data-plane token");
|
|
206
216
|
}
|
|
207
217
|
if (options.allowNetwork === false) return fromCache("a live hub read was not attempted");
|
|
208
218
|
let state: HubStateDTO;
|
|
209
219
|
try {
|
|
210
|
-
state = await fetchHubState(options.owner.serverUrl, options.token, {
|
|
220
|
+
state = await fetchHubState(options.owner.serverUrl, options.localRelayFetchImpl ? null : options.token, {
|
|
211
221
|
timeoutMs: options.timeoutMs ?? DEFAULT_HUB_STATE_TIMEOUT_MS,
|
|
212
|
-
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
222
|
+
...((options.localRelayFetchImpl ?? options.fetchImpl) ? { fetchImpl: options.localRelayFetchImpl ?? options.fetchImpl } : {}),
|
|
213
223
|
});
|
|
214
224
|
} catch (error) {
|
|
215
225
|
// Deliberately no local-state fallback here. A stale cache is still the HUB's state; the
|
|
216
226
|
// client's own providers and logins are not, at any age.
|
|
217
|
-
return
|
|
227
|
+
if (error instanceof HubClientError && error.code === "hub_state_not_a_hub") return withAge("unavailable", null, undefined, now, hubStateFailureReason(error, options.peer));
|
|
228
|
+
return fromCache(hubStateFailureReason(error, options.peer));
|
|
218
229
|
}
|
|
219
230
|
const fetchedAt = new Date(now).toISOString();
|
|
220
231
|
if (options.persist !== false) writeCachedHubState(options.owner, state, fetchedAt);
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Remote Link Child's view of its Home (#6957): the Home's name for refusal and status text,
|
|
3
|
+
* the Home's OpenCodex-owned Codex settings, and authenticated read-only management reads.
|
|
4
|
+
*
|
|
5
|
+
* Every read goes through the connection-bound link relay (`fetchBoundLinkRelay`): the link key
|
|
6
|
+
* is sent only after the listener on the tunnel port proves it holds the same link identity, so a
|
|
7
|
+
* process that took the tunnel port never sees the key.
|
|
8
|
+
*/
|
|
9
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { atomicWriteFile, isMissingPathError } from "../config/atomic-write";
|
|
12
|
+
import { readBoundedResponseBytes } from "../lib/bounded-body";
|
|
13
|
+
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
14
|
+
import { hardenSecretDir } from "../lib/windows-secret-acl";
|
|
15
|
+
import {
|
|
16
|
+
parseHomeCodexSettingsDocument,
|
|
17
|
+
type HomeCodexSettingsDocument,
|
|
18
|
+
} from "../link/codex-settings";
|
|
19
|
+
import {
|
|
20
|
+
LINK_CODEX_SETTINGS_PATH,
|
|
21
|
+
LINK_MANAGEMENT_PREFIX,
|
|
22
|
+
linkManagementReadAllowed,
|
|
23
|
+
} from "../link/management-routes";
|
|
24
|
+
import { linkDir } from "../link/paths";
|
|
25
|
+
import type { OcxClientConnectionConfig } from "../types";
|
|
26
|
+
import { fetchBoundLinkRelay } from "./link-relay-transport";
|
|
27
|
+
import { readClientLinkState, type ClientLinkState } from "./link-state";
|
|
28
|
+
|
|
29
|
+
const RECORD_MAX_BYTES = 64 * 1024;
|
|
30
|
+
const SETTINGS_MAX_BYTES = 64 * 1024;
|
|
31
|
+
const READ_TIMEOUT_MS = 10_000;
|
|
32
|
+
|
|
33
|
+
export interface LinkHomeDeps {
|
|
34
|
+
/** Trusted complete-send test seam; production uses the connection-bound transport. */
|
|
35
|
+
fetchImpl?: typeof fetch;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The connection fields a Home read needs; connect calls these before its state is committed. */
|
|
40
|
+
export type LinkHomeConnection = Pick<OcxClientConnectionConfig, "transport" | "link" | "apiKeyId">;
|
|
41
|
+
|
|
42
|
+
export interface HomeCodexSettingsRecord {
|
|
43
|
+
linkId: string;
|
|
44
|
+
fetchedAt: string;
|
|
45
|
+
document: HomeCodexSettingsDocument;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function homeCodexSettingsPath(): string {
|
|
49
|
+
return join(linkDir(), "home-codex-settings.json");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function linkTarget(connection: LinkHomeConnection, admissionKey: string) {
|
|
53
|
+
if (connection.transport !== "link" || !connection.link) throw new Error("not a Remote Link Child");
|
|
54
|
+
return {
|
|
55
|
+
tunnelPort: connection.link.tunnelPort,
|
|
56
|
+
linkId: connection.link.linkId,
|
|
57
|
+
apiKeyId: connection.apiKeyId,
|
|
58
|
+
admissionKey,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The last applied Home settings for this link, or null when absent, malformed or another link's. */
|
|
63
|
+
export function readHomeCodexSettingsRecord(linkId: string, path = homeCodexSettingsPath()): HomeCodexSettingsRecord | null {
|
|
64
|
+
try {
|
|
65
|
+
const stat = lstatSync(path);
|
|
66
|
+
if (!stat.isFile() || stat.size > RECORD_MAX_BYTES) return null;
|
|
67
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
68
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
69
|
+
const record = raw as Record<string, unknown>;
|
|
70
|
+
if (record.linkId !== linkId || typeof record.fetchedAt !== "string" || Number.isNaN(Date.parse(record.fetchedAt))) return null;
|
|
71
|
+
const document = parseHomeCodexSettingsDocument(record.document);
|
|
72
|
+
return document ? { linkId, fetchedAt: record.fetchedAt, document } : null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function writeHomeCodexSettingsRecord(record: HomeCodexSettingsRecord, path = homeCodexSettingsPath()): void {
|
|
79
|
+
const dir = linkDir();
|
|
80
|
+
assertNotRealHomeUnderTest(join(dir, ".."));
|
|
81
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
82
|
+
if (process.platform === "win32") hardenSecretDir(dir, { required: true });
|
|
83
|
+
else chmodSync(dir, 0o700);
|
|
84
|
+
atomicWriteFile(path, `${JSON.stringify(record, null, 2)}\n`);
|
|
85
|
+
if (process.platform !== "win32") chmodSync(path, 0o600);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function clearHomeCodexSettingsRecord(path = homeCodexSettingsPath()): void {
|
|
89
|
+
try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The Home's name as this Child knows it: the SSH alias of a Child-initiated link, else the
|
|
94
|
+
* host name the Home published with its Codex settings, else null ("its Home").
|
|
95
|
+
*/
|
|
96
|
+
export function linkHomeName(
|
|
97
|
+
connection: OcxClientConnectionConfig,
|
|
98
|
+
readSidecar: () => ClientLinkState | null = readClientLinkState,
|
|
99
|
+
): string | null {
|
|
100
|
+
const linkId = connection.link?.linkId;
|
|
101
|
+
if (!linkId) return null;
|
|
102
|
+
try {
|
|
103
|
+
const sidecar = readSidecar();
|
|
104
|
+
if (sidecar && sidecar.linkId === linkId) return sidecar.alias;
|
|
105
|
+
} catch { /* an unreadable sidecar names nothing */ }
|
|
106
|
+
return readHomeCodexSettingsRecord(linkId)?.document.home.name ?? null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** "Home home-box" or "its Home": the phrase every link Child message uses. */
|
|
110
|
+
export function linkHomePhrase(
|
|
111
|
+
connection: OcxClientConnectionConfig,
|
|
112
|
+
readSidecar?: () => ClientLinkState | null,
|
|
113
|
+
): string {
|
|
114
|
+
const name = linkHomeName(connection, readSidecar);
|
|
115
|
+
return name ? `Home ${name}` : "its Home";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A `fetch` for the existing data-plane reads (`/v1/hub-state`, `/v1/usage`) that sends each
|
|
120
|
+
* request through the connection-bound link relay, so the link key reaches only the Home.
|
|
121
|
+
*/
|
|
122
|
+
export function linkBoundFetch(connection: LinkHomeConnection, admissionKey: string): typeof fetch {
|
|
123
|
+
const target = linkTarget(connection, admissionKey);
|
|
124
|
+
return ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
125
|
+
// A Request keeps its method, headers and signal unless init overrides them.
|
|
126
|
+
const request = input instanceof Request ? input : null;
|
|
127
|
+
const url = request ? request.url : String(input);
|
|
128
|
+
return fetchBoundLinkRelay(target, url, {
|
|
129
|
+
...(request ? { method: request.method, headers: request.headers, signal: request.signal } : {}),
|
|
130
|
+
...(init ?? {}),
|
|
131
|
+
redirect: "manual",
|
|
132
|
+
});
|
|
133
|
+
}) as typeof fetch;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* One authenticated, read-only management read on the Home. `path` is a Home management path
|
|
138
|
+
* with an optional query (`/api/providers`); anything outside the shared allowlist throws before
|
|
139
|
+
* a request is built.
|
|
140
|
+
*/
|
|
141
|
+
export async function fetchLinkHomeManagement(
|
|
142
|
+
connection: LinkHomeConnection,
|
|
143
|
+
admissionKey: string,
|
|
144
|
+
path: string,
|
|
145
|
+
init: { method?: "GET" | "HEAD"; signal?: AbortSignal } = {},
|
|
146
|
+
deps: LinkHomeDeps = {},
|
|
147
|
+
): Promise<Response> {
|
|
148
|
+
const method = init.method ?? "GET";
|
|
149
|
+
const local = new URL(path, "http://link.invalid");
|
|
150
|
+
if (local.origin !== "http://link.invalid" || !linkManagementReadAllowed(method, local.pathname, local.searchParams)) {
|
|
151
|
+
throw new Error("path is not readable over the Remote Link");
|
|
152
|
+
}
|
|
153
|
+
const target = linkTarget(connection, admissionKey);
|
|
154
|
+
const destination = `http://127.0.0.1:${target.tunnelPort}${LINK_MANAGEMENT_PREFIX}${local.pathname}${local.search}`;
|
|
155
|
+
const signal = init.signal
|
|
156
|
+
? AbortSignal.any([init.signal, AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS)])
|
|
157
|
+
: AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS);
|
|
158
|
+
const request: RequestInit = {
|
|
159
|
+
method,
|
|
160
|
+
headers: { accept: "application/json", authorization: `Bearer ${admissionKey}` },
|
|
161
|
+
redirect: "manual",
|
|
162
|
+
signal,
|
|
163
|
+
};
|
|
164
|
+
return deps.fetchImpl ? deps.fetchImpl(destination, request) : fetchBoundLinkRelay(target, destination, request);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type HomeCodexSettingsFetch =
|
|
168
|
+
| { kind: "fresh"; document: HomeCodexSettingsDocument }
|
|
169
|
+
| { kind: "unsupported" }
|
|
170
|
+
| { kind: "failed"; reason: string };
|
|
171
|
+
|
|
172
|
+
export async function fetchHomeCodexSettings(
|
|
173
|
+
connection: LinkHomeConnection,
|
|
174
|
+
admissionKey: string,
|
|
175
|
+
deps: LinkHomeDeps = {},
|
|
176
|
+
): Promise<HomeCodexSettingsFetch> {
|
|
177
|
+
let response: Response;
|
|
178
|
+
try {
|
|
179
|
+
const target = linkTarget(connection, admissionKey);
|
|
180
|
+
const destination = `http://127.0.0.1:${target.tunnelPort}${LINK_CODEX_SETTINGS_PATH}`;
|
|
181
|
+
const request: RequestInit = {
|
|
182
|
+
method: "GET",
|
|
183
|
+
headers: { accept: "application/json", authorization: `Bearer ${admissionKey}` },
|
|
184
|
+
redirect: "manual",
|
|
185
|
+
signal: AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS),
|
|
186
|
+
};
|
|
187
|
+
response = deps.fetchImpl ? await deps.fetchImpl(destination, request) : await fetchBoundLinkRelay(target, destination, request);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return { kind: "failed", reason: error instanceof Error && error.name === "LinkRelayAuthenticationError" ? error.message : "the Home is unreachable" };
|
|
190
|
+
}
|
|
191
|
+
if (response.status === 404) {
|
|
192
|
+
try { await response.body?.cancel(); } catch { /* best effort */ }
|
|
193
|
+
return { kind: "unsupported" };
|
|
194
|
+
}
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
try { await response.body?.cancel(); } catch { /* best effort */ }
|
|
197
|
+
return { kind: "failed", reason: `the Home answered HTTP ${response.status}` };
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const result = await readBoundedResponseBytes(response, { maxBytes: SETTINGS_MAX_BYTES, signal: AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS) });
|
|
201
|
+
if (result.oversized) return { kind: "failed", reason: "the Home's settings exceeded the allowed size" };
|
|
202
|
+
const document = parseHomeCodexSettingsDocument(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(result.bytes)));
|
|
203
|
+
return document ? { kind: "fresh", document } : { kind: "failed", reason: "the Home returned an unreadable settings document" };
|
|
204
|
+
} catch {
|
|
205
|
+
return { kind: "failed", reason: "the Home returned an unreadable settings document" };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export type HomeCodexSettingsSource =
|
|
210
|
+
| { source: "home"; document: HomeCodexSettingsDocument; fetchedAt: string }
|
|
211
|
+
| { source: "cached"; document: HomeCodexSettingsDocument; fetchedAt: string; reason: string }
|
|
212
|
+
| { source: "local"; reason: string };
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The settings a sync applies. A failed read reuses the last record for this link so a brief
|
|
216
|
+
* outage never flips the Child back to its local values; a Home that predates the route leaves
|
|
217
|
+
* the Child's local values in place and says so. A fresh read is NOT recorded here: the caller
|
|
218
|
+
* records it with `recordHomeCodexSettings` only after the injection that applies it succeeds,
|
|
219
|
+
* because `ocx status` reads the record as "applied".
|
|
220
|
+
*/
|
|
221
|
+
export async function resolveHomeCodexSettings(
|
|
222
|
+
connection: LinkHomeConnection,
|
|
223
|
+
admissionKey: string,
|
|
224
|
+
deps: LinkHomeDeps & { now?: () => Date } = {},
|
|
225
|
+
): Promise<HomeCodexSettingsSource> {
|
|
226
|
+
const linkId = connection.link?.linkId;
|
|
227
|
+
if (!linkId) return { source: "local", reason: "not a Remote Link Child" };
|
|
228
|
+
const fetched = await fetchHomeCodexSettings(connection, admissionKey, deps);
|
|
229
|
+
if (fetched.kind === "fresh") {
|
|
230
|
+
return { source: "home", document: fetched.document, fetchedAt: (deps.now ?? (() => new Date()))().toISOString() };
|
|
231
|
+
}
|
|
232
|
+
if (fetched.kind === "unsupported") {
|
|
233
|
+
clearHomeCodexSettingsRecord();
|
|
234
|
+
return { source: "local", reason: "the Home does not publish Codex settings; upgrade the Home" };
|
|
235
|
+
}
|
|
236
|
+
const cached = readHomeCodexSettingsRecord(linkId);
|
|
237
|
+
return cached
|
|
238
|
+
? { source: "cached", document: cached.document, fetchedAt: cached.fetchedAt, reason: fetched.reason }
|
|
239
|
+
: { source: "local", reason: fetched.reason };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Record settings a successful injection applied. Best effort: the config is already written. */
|
|
243
|
+
export function recordHomeCodexSettings(linkId: string, settings: HomeCodexSettingsSource): void {
|
|
244
|
+
if (settings.source !== "home") return;
|
|
245
|
+
try {
|
|
246
|
+
writeHomeCodexSettingsRecord({ linkId, fetchedAt: settings.fetchedAt, document: settings.document });
|
|
247
|
+
} catch { /* the next sync records it */ }
|
|
248
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Child end of the Remote Link management relay (#6957).
|
|
3
|
+
*
|
|
4
|
+
* The Child's dashboard reaches its Home's management reads at `/api/machine/hub-relay/api/*`,
|
|
5
|
+
* the same base a hub client's dashboard uses. The browser authenticates to the Child with the
|
|
6
|
+
* Child's own dashboard session; this relay drops every browser header, builds a fresh GET for
|
|
7
|
+
* the Home's `/v1/link-management` prefix and sends it through the connection-bound link relay,
|
|
8
|
+
* which attaches the link key only after the tunnel listener proves the link identity. Anything
|
|
9
|
+
* outside the shared read allowlist is refused here, before a request exists.
|
|
10
|
+
*/
|
|
11
|
+
import { HUB_RELAY_RESPONSE_BODY_MAX_BYTES } from "./hub-relay";
|
|
12
|
+
import type { LinkIngress } from "./link-ingress";
|
|
13
|
+
import { relayLinkDataRequest } from "./link-relay";
|
|
14
|
+
import {
|
|
15
|
+
LINK_MANAGEMENT_PREFIX,
|
|
16
|
+
linkChildManagementRefusal,
|
|
17
|
+
linkManagementReadAllowed,
|
|
18
|
+
linkManagementRouteAllowed,
|
|
19
|
+
} from "../link/management-routes";
|
|
20
|
+
|
|
21
|
+
export const LINK_CHILD_READ_ONLY = "link_child_read_only";
|
|
22
|
+
|
|
23
|
+
export function linkChildRefusalResponse(homePhrase: string, linkId: string, operation: string): Response {
|
|
24
|
+
return Response.json({
|
|
25
|
+
error: linkChildManagementRefusal(homePhrase, linkId, operation),
|
|
26
|
+
code: LINK_CHILD_READ_ONLY,
|
|
27
|
+
linkId,
|
|
28
|
+
}, { status: 403, headers: { "cache-control": "no-store" } });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function relayLinkManagementRead(
|
|
32
|
+
req: Request,
|
|
33
|
+
suffix: string,
|
|
34
|
+
ingress: LinkIngress,
|
|
35
|
+
homePhrase: string,
|
|
36
|
+
): Promise<Response> {
|
|
37
|
+
const linkId = ingress.linkId ?? "unknown";
|
|
38
|
+
const query = suffix.indexOf("?");
|
|
39
|
+
const pathname = query === -1 ? suffix : suffix.slice(0, query);
|
|
40
|
+
const search = query === -1 ? "" : suffix.slice(query);
|
|
41
|
+
if (!linkManagementReadAllowed(req.method, pathname, new URLSearchParams(search.slice(1)))) {
|
|
42
|
+
return linkChildRefusalResponse(homePhrase, linkId, `${req.method.toUpperCase()} ${pathname}`);
|
|
43
|
+
}
|
|
44
|
+
const admissionKey = ingress.linkKey();
|
|
45
|
+
if (!admissionKey) return Response.json({ error: "link_credential_unavailable" }, { status: 503 });
|
|
46
|
+
const outbound = new Request(`http://127.0.0.1${LINK_MANAGEMENT_PREFIX}${pathname}${search}`, {
|
|
47
|
+
method: req.method,
|
|
48
|
+
headers: { accept: "application/json" },
|
|
49
|
+
signal: req.signal,
|
|
50
|
+
});
|
|
51
|
+
return relayLinkDataRequest(outbound, {
|
|
52
|
+
tunnelPort: ingress.tunnelPort,
|
|
53
|
+
admissionKey,
|
|
54
|
+
linkId: ingress.linkId,
|
|
55
|
+
apiKeyId: ingress.apiKeyId,
|
|
56
|
+
}, {
|
|
57
|
+
...ingress.relay,
|
|
58
|
+
bodyLimitBytes: HUB_RELAY_RESPONSE_BODY_MAX_BYTES,
|
|
59
|
+
routeAllowed: linkManagementRouteAllowed,
|
|
60
|
+
});
|
|
61
|
+
}
|
package/src/client/link-relay.ts
CHANGED
|
@@ -69,6 +69,11 @@ export interface LinkRelayDeps {
|
|
|
69
69
|
/** The longest a request waits for a reconnecting tunnel, from its first wait. */
|
|
70
70
|
holdMs?: number;
|
|
71
71
|
now?: () => number;
|
|
72
|
+
/**
|
|
73
|
+
* The route table this relay enforces. Defaults to the data-plane table; only the Child's
|
|
74
|
+
* authenticated management relay passes the read-only management table (#6957).
|
|
75
|
+
*/
|
|
76
|
+
routeAllowed?: (url: URL, req: Request) => boolean;
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
export const LINK_RELAY_RETRY_AFTER_SECONDS = 1;
|
|
@@ -305,7 +310,7 @@ export async function relayLinkDataRequest(
|
|
|
305
310
|
deps: LinkRelayDeps = {},
|
|
306
311
|
): Promise<Response> {
|
|
307
312
|
const url = new URL(req.url);
|
|
308
|
-
if (!linkRouteAllowed(url, req)) return jsonError(404, "not_found");
|
|
313
|
+
if (!(deps.routeAllowed ?? linkRouteAllowed)(url, req)) return jsonError(404, "not_found");
|
|
309
314
|
let destination: string;
|
|
310
315
|
try { destination = linkRelayDestination(url, target); } catch { return jsonError(404, "not_found"); }
|
|
311
316
|
const validation = validateHubRelayRequestHeaders(linkFramingHeaders(req.headers));
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import { isMissingPathError } from "../config/atomic-write";
|
|
3
|
+
import { readClientLinkState, childLinkMarkerPath } from "./link-state";
|
|
4
|
+
import type { ClientLinkIdentity } from "../link/status-projection";
|
|
1
5
|
import type { LinkStatusDto } from "../link/status-projection";
|
|
2
6
|
import type { ClientLinkState } from "./link-state";
|
|
3
7
|
import type { ClientLinkSupervisorStatus } from "./link-tunnel";
|
|
@@ -37,3 +41,13 @@ function childState(sidecar: ClientLinkSidecarRead, supervisor: ClientLinkSuperv
|
|
|
37
41
|
reason: supervisor.probe ?? (tunnel.kind === "failed" ? tunnel.reason : null),
|
|
38
42
|
};
|
|
39
43
|
}
|
|
44
|
+
|
|
45
|
+
/** Only client-owned evidence can establish who owns the tunnel. Unreadable evidence stays unknown. */
|
|
46
|
+
export function clientLinkStatusIdentity(linkId: string): ClientLinkIdentity | null {
|
|
47
|
+
try {
|
|
48
|
+
const sidecar = readClientLinkState();
|
|
49
|
+
if (sidecar) return sidecar.linkId === linkId ? { direction: "client-initiated", alias: sidecar.alias } : null;
|
|
50
|
+
try { lstatSync(childLinkMarkerPath()); return null; }
|
|
51
|
+
catch (error) { return isMissingPathError(error) ? { direction: "hub-initiated" } : null; }
|
|
52
|
+
} catch { return null; }
|
|
53
|
+
}
|
|
@@ -3,6 +3,7 @@ import { diagnoseCodexShim, installCodexShim, uninstallCodexShim } from "../code
|
|
|
3
3
|
import { readManagementJsonBody } from "../server/management/body";
|
|
4
4
|
import type { OcxClientConnectionConfig } from "../types";
|
|
5
5
|
import { disconnectClient, syncConnectedClient } from "./connect";
|
|
6
|
+
import { linkHomeName } from "./link-home";
|
|
6
7
|
import { isLinkConnection } from "./state";
|
|
7
8
|
|
|
8
9
|
export type HubReachability = "unknown" | "online" | "offline" | "unauthorized";
|
|
@@ -19,6 +20,8 @@ export interface MachineStatusV1 {
|
|
|
19
20
|
connectedAt: string;
|
|
20
21
|
catalogSyncedAt?: string;
|
|
21
22
|
hubReachability: HubReachability;
|
|
23
|
+
/** Present only on a Remote Link Child: the dashboard shows "managed by Home <name>". */
|
|
24
|
+
link?: { linkId: string; home: string | null; readOnly: true };
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export interface MachineApiDeps {
|
|
@@ -27,6 +30,8 @@ export interface MachineApiDeps {
|
|
|
27
30
|
scheduleStandaloneRecycle: (disconnectedTokenFingerprint: string) => void;
|
|
28
31
|
hubReachability?: () => HubReachability;
|
|
29
32
|
setHubReachability?: (value: HubReachability) => void;
|
|
33
|
+
/** Link mode: the Home's name as this listener knows it (sidecar alias or recorded host name). */
|
|
34
|
+
linkHomeName?: () => string | null;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
const defaultDeps: MachineApiDeps = {
|
|
@@ -52,24 +57,26 @@ async function jsonBody(req: Request): Promise<unknown | Response> {
|
|
|
52
57
|
function statusPayload(req: Request, state: OcxClientConnectionConfig, deps: MachineApiDeps): MachineStatusV1 {
|
|
53
58
|
const machineBase = new URL(req.url).origin;
|
|
54
59
|
// A link Child's management URL is the tunnel, whose hub-link ingress serves no /api/* and no
|
|
55
|
-
// session bootstrap. Its dashboard
|
|
60
|
+
// session bootstrap. Its dashboard therefore uses this listener's relay base: the browser holds
|
|
61
|
+
// only this listener's session, and the listener relays allowlisted reads to the Home with the
|
|
62
|
+
// link key (#6957, src/client/link-management-relay.ts).
|
|
56
63
|
const linkMode = isLinkConnection(state);
|
|
64
|
+
const relay = linkMode || state.managementTransport === "relay";
|
|
57
65
|
return {
|
|
58
66
|
mode: "client",
|
|
59
67
|
connected: true,
|
|
60
68
|
machineBase,
|
|
61
|
-
sharedBase:
|
|
62
|
-
? machineBase
|
|
63
|
-
: state.managementTransport === "relay"
|
|
64
|
-
? `${machineBase}/api/machine/hub-relay`
|
|
65
|
-
: state.managementUrl,
|
|
69
|
+
sharedBase: relay ? `${machineBase}/api/machine/hub-relay` : state.managementUrl,
|
|
66
70
|
sharedServerOrigin: linkMode ? machineBase : state.managementUrl,
|
|
67
|
-
managementTransport: state.managementTransport,
|
|
71
|
+
managementTransport: relay ? "relay" : state.managementTransport,
|
|
68
72
|
apiKeyId: state.apiKeyId,
|
|
69
73
|
protocolVersion: state.protocolVersion,
|
|
70
74
|
connectedAt: state.connectedAt,
|
|
71
75
|
...(state.catalogSyncedAt ? { catalogSyncedAt: state.catalogSyncedAt } : {}),
|
|
72
76
|
hubReachability: deps.hubReachability?.() ?? "unknown",
|
|
77
|
+
...(linkMode && state.link
|
|
78
|
+
? { link: { linkId: state.link.linkId, home: deps.linkHomeName ? deps.linkHomeName() : linkHomeName(state), readOnly: true as const } }
|
|
79
|
+
: {}),
|
|
73
80
|
};
|
|
74
81
|
}
|
|
75
82
|
|
|
@@ -23,6 +23,8 @@ import { projectClientLinkChild, type ClientLinkSidecarRead } from "./link-statu
|
|
|
23
23
|
import type { ClientLinkSupervisorStatus } from "./link-tunnel";
|
|
24
24
|
import { packageVersion } from "../lib/package-version";
|
|
25
25
|
import { linkRouteAllowed } from "../link/routes";
|
|
26
|
+
import { linkHomeName, linkHomePhrase } from "./link-home";
|
|
27
|
+
import { relayLinkManagementRead } from "./link-management-relay";
|
|
26
28
|
|
|
27
29
|
const VERSION = packageVersion("0.0.0");
|
|
28
30
|
const GUI_SPA_PATHS = new Set([
|
|
@@ -100,6 +102,8 @@ export function startMachineListener(
|
|
|
100
102
|
if (linkMode && !connection.link) throw new Error("link machine listener requires link transport metadata");
|
|
101
103
|
const managementAuth = deps.managementAuthState ?? initializeManagementAuthState(config);
|
|
102
104
|
let hubReachability: HubReachability = "unknown";
|
|
105
|
+
const readSidecar = deps.readSidecar ?? (() => readClientLinkState());
|
|
106
|
+
const sidecarOrNull = () => { const read = readSidecarSafely(readSidecar); return read === "invalid" ? null : read; };
|
|
103
107
|
const machineApiDeps: MachineApiDeps = {
|
|
104
108
|
sync: deps.machineApi?.sync ?? syncConnectedClient,
|
|
105
109
|
disconnect: deps.machineApi?.disconnect ?? disconnectClient,
|
|
@@ -108,8 +112,11 @@ export function startMachineListener(
|
|
|
108
112
|
}),
|
|
109
113
|
hubReachability: deps.machineApi?.hubReachability ?? (() => hubReachability),
|
|
110
114
|
setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }),
|
|
115
|
+
...(linkMode ? { linkHomeName: () => linkHomeName(connection, sidecarOrNull) } : {}),
|
|
111
116
|
};
|
|
112
|
-
|
|
117
|
+
// A link Child relays its dashboard's management reads to the Home over the link (#6957);
|
|
118
|
+
// a hub client relays over its management transport when that is `relay`.
|
|
119
|
+
const relayEnabled = linkMode || connection.managementTransport === "relay";
|
|
113
120
|
// Link mode only. Everything is resolved once here, so a relayed request reads no file and
|
|
114
121
|
// builds no policy: the key is cached, the loopback policy is fixed at bind like the listener.
|
|
115
122
|
const inboundBodyLimit = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes);
|
|
@@ -123,8 +130,6 @@ export function startMachineListener(
|
|
|
123
130
|
relay: { fetchImpl: deps.fetchImpl, bodyLimitBytes: inboundBodyLimit, tunnel: deps.linkTunnel, ...deps.linkRelay },
|
|
124
131
|
}
|
|
125
132
|
: null;
|
|
126
|
-
const readSidecar = deps.readSidecar ?? (() => readClientLinkState());
|
|
127
|
-
|
|
128
133
|
return (deps.serve ?? (options => Bun.serve(options)))({
|
|
129
134
|
port: port ?? config.port ?? 10100,
|
|
130
135
|
hostname: "127.0.0.1",
|
|
@@ -147,9 +152,29 @@ export function startMachineListener(
|
|
|
147
152
|
}
|
|
148
153
|
if (url.pathname.startsWith("/api/machine/hub-relay/")) {
|
|
149
154
|
if (!relayEnabled) return json404(req);
|
|
155
|
+
const prefix = "/api/machine/hub-relay";
|
|
156
|
+
if (linkIngress) {
|
|
157
|
+
const suffix = `${url.pathname.slice(prefix.length)}${url.search}`;
|
|
158
|
+
// The dashboard's shared plane bootstraps here. On a link Child that plane is this
|
|
159
|
+
// listener, so it receives this listener's own session; no Home credential exists.
|
|
160
|
+
if (suffix === "/opencodex-session") {
|
|
161
|
+
if (req.method !== "GET") return json404(req);
|
|
162
|
+
const session = issueGuiSession(req, config, managementAuth, { trustedTailscaleIngress: false });
|
|
163
|
+
return session ? serveSessionBootstrap(session) : Response.json({ error: "session unavailable" }, { status: 503 });
|
|
164
|
+
}
|
|
165
|
+
const authError = requireManagementAuth(req, managementAuth, config);
|
|
166
|
+
if (authError) return authError;
|
|
167
|
+
if (managementPrincipal(req, managementAuth, config) !== "gui-session") {
|
|
168
|
+
return Response.json({ error: "opencodex machine GUI session required" }, { status: 401 });
|
|
169
|
+
}
|
|
170
|
+
const response = await relayLinkManagementRead(req, suffix, linkIngress, linkHomePhrase(connection, sidecarOrNull));
|
|
171
|
+
if (response.status === 401) hubReachability = "unauthorized";
|
|
172
|
+
else if (response.status >= 500) hubReachability = "offline";
|
|
173
|
+
else if (response.status !== 403) hubReachability = "online";
|
|
174
|
+
return response;
|
|
175
|
+
}
|
|
150
176
|
const authError = requireMachineAuth(req, managementAuth, config);
|
|
151
177
|
if (authError) return authError;
|
|
152
|
-
const prefix = "/api/machine/hub-relay";
|
|
153
178
|
const suffix = `${url.pathname.slice(prefix.length)}${url.search}`;
|
|
154
179
|
const response = await relayHubManagementRequest(req, suffix, {
|
|
155
180
|
managementUrl: connection.managementUrl,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { getModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
|
|
3
3
|
import { SCHEMA_REQUIRED_OUTPUT_BUDGET } from "./constants";
|
|
4
4
|
import { expandFastExportModels } from "./fast-models";
|
|
5
|
+
import { sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
|
|
5
6
|
import type { OpencodeCatalogModel, ExportModel, ExportClientId, ManagedContribution } from "./contracts";
|
|
6
7
|
import type { OcxConfig } from "../../types";
|
|
7
8
|
import { shouldInjectApiAuthHeader } from "../../codex/inject";
|
|
@@ -84,6 +85,21 @@ export function inputModalitiesForClient(
|
|
|
84
85
|
return kept.length > 0 ? kept : null;
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Effort ladder a ZCode surface can offer as picker values, or undefined when the row
|
|
90
|
+
* has nothing selectable.
|
|
91
|
+
*
|
|
92
|
+
* Both ZCode exports — the legacy `v2/config.json` block's `reasoning.variants` and the
|
|
93
|
+
* `v2/provider_config.json` store's `optionSpecs.reasoningLevel.values` — carry the same
|
|
94
|
+
* Codex vocabulary: the client validates the chosen level against the emitted list and
|
|
95
|
+
* forwards it verbatim, and `/v1/responses` reads that vocabulary natively. `none` is a
|
|
96
|
+
* Codex omit-sentinel, not a picker option, so it never survives into the values.
|
|
97
|
+
*/
|
|
98
|
+
export function zcodeSelectableEfforts(efforts: readonly string[] | undefined): string[] | undefined {
|
|
99
|
+
const selectable = sanitizeCodexReasoningEfforts(efforts)?.filter(effort => effort !== "none");
|
|
100
|
+
return selectable && selectable.length > 0 ? selectable : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
/**
|
|
88
104
|
* Input modalities opencode's model schema accepts (opencode.ai/config.json, both
|
|
89
105
|
* `modalities.input` and `modalities.output`). Wider than our internal `text | image | audio`
|