@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
|
@@ -1,126 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { parseFingerprintLine } from "../../link/fingerprint";
|
|
7
|
-
import { awaitFirstAdmission } from "../../link/admission-wait";
|
|
8
|
-
import { linkKnownHostsPath, linkStorePath } from "../../link/paths";
|
|
9
|
-
import { clearCompensationFailed, compensationPath, markCompensationFailed, readCompensation } from "../../link/compensation";
|
|
1
|
+
import { probe, confirmHost, apply } from "./link-enrollment";
|
|
2
|
+
import { fail, pairedSession, auth, port, isRecord, exactBody, runnerFor, readStoreFor, writeStoreFor, knownHostsFile, issueKey, revokeKeyIdempotent, compensateNewLink, type LinkRouteState } from "./link-route-state";
|
|
3
|
+
export type { LinkRouteState, PendingLinkHost, ConfirmedLinkHost, LinkRouteListener } from "./link-route-state";
|
|
4
|
+
import { assertSshAlias, buildExecArgv, remoteOcxArgv } from "../../link/ssh-argv";
|
|
5
|
+
import { clearCompensationFailed, compensationPath, readCompensation } from "../../link/compensation";
|
|
10
6
|
import { loadHostCandidates } from "../../link/ssh-config";
|
|
11
|
-
import { newLinkId,
|
|
12
|
-
import {
|
|
7
|
+
import { newLinkId, type LinkStore } from "../../link/store";
|
|
8
|
+
import { sshFailureHint, sshRunnerErrorHint } from "../../link/ssh-runner";
|
|
13
9
|
import { projectLinkStatus, type LinkStatusDto } from "../../link/status-projection";
|
|
14
10
|
import { isLinkPort } from "../../link/ports";
|
|
15
11
|
import { joinHome, type ClientLinkJoinDeps } from "../../client/link-join";
|
|
12
|
+
import { clientLinkStatusIdentity } from "../../client/link-status";
|
|
16
13
|
import { clientLinkTunnelStatus } from "../../client/link-tunnel";
|
|
14
|
+
import { withConfigMutationLockSync } from "../../config/mutation-lock";
|
|
17
15
|
import type { ManagementContext } from "./context";
|
|
18
16
|
import { readManagementJsonBodyOr } from "./body";
|
|
19
|
-
import {
|
|
17
|
+
import { type IssuedApiKey } from "./oauth-account-routes";
|
|
20
18
|
import { acceptSystemRestart, resolveListenPort } from "./system-restart";
|
|
21
19
|
|
|
22
|
-
const PROBE_TTL_MS = 5 * 60_000;
|
|
23
|
-
const APPLY_ADMISSION_TIMEOUT_MS = 15_000;
|
|
24
20
|
const LINK_ID = /^lnk_[0-9a-f]{16}$/;
|
|
25
|
-
// `ocx link` and `ocx connect --link` first shipped in 2.66.0; an older remote passes --version
|
|
26
|
-
// and then fails at apply with remote_port_failed.
|
|
27
|
-
const MIN_REMOTE_OCX = [2, 66, 0] as const;
|
|
28
|
-
// A bounded semver shape: the parsed version reaches the confirm-host body and the outdated hint,
|
|
29
|
-
// so the tail admits only a pre-release and build of semver identifier characters and must end
|
|
30
|
-
// the token. Anything else attached to the version makes the output unrecognized.
|
|
31
|
-
const REMOTE_OCX_VERSION = /^opencodex (\d{1,9})\.(\d{1,9})\.(\d{1,9})(-[0-9A-Za-z.-]{1,64})?(\+[0-9A-Za-z.-]{1,64})?(?=\s|$)/;
|
|
32
21
|
const isLinkPath = (path: string): boolean => path === "/api/link" || path.startsWith("/api/link/");
|
|
33
22
|
|
|
34
|
-
export interface PendingLinkHost {
|
|
35
|
-
alias: string;
|
|
36
|
-
fingerprint: string;
|
|
37
|
-
keyType: string;
|
|
38
|
-
knownHostLine: string;
|
|
39
|
-
probedAt: number;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface ConfirmedLinkHost extends PendingLinkHost {
|
|
43
|
-
ocxVersion: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface LinkRouteListener {
|
|
47
|
-
ensureStarted(): Promise<void>;
|
|
48
|
-
status(): { state: "off" | "listening" | "failed"; port: number | null; reason: string | null };
|
|
49
|
-
close(): Promise<void>;
|
|
50
|
-
onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface LinkRouteState {
|
|
54
|
-
pendingHosts: Map<string, PendingLinkHost>;
|
|
55
|
-
confirmedHosts?: Map<string, ConfirmedLinkHost>;
|
|
56
|
-
compensationFailures?: Map<string, { since: string; reason: "compensation_failed" }>;
|
|
57
|
-
supervisor: import("../../link/supervisor").LinkSupervisor;
|
|
58
|
-
listener: LinkRouteListener;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
23
|
const states = new WeakMap<object, LinkRouteState>();
|
|
62
24
|
// One process runs at most one join: a join ends by restarting this process as a client.
|
|
63
25
|
let joinInProgress = false;
|
|
64
26
|
|
|
65
|
-
/**
|
|
66
|
-
* `hint` is one line bounded by `boundHint`: from ssh stderr (`sshFailureHint`), from the ssh
|
|
67
|
-
* runner's own failure (`sshRunnerErrorHint`), or the parsed remote version for
|
|
68
|
-
* `remote_ocx_outdated`. It is returned, never logged.
|
|
69
|
-
*/
|
|
70
|
-
function fail(code: string, message: string, status: number, hint?: string): Response {
|
|
71
|
-
return Response.json({ error: { code, message, ...(hint ? { hint } : {}) } }, { status, headers: { "cache-control": "no-store" } });
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
75
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function exactBody(body: unknown, keys: readonly string[]): Record<string, unknown> | null {
|
|
79
|
-
if (!isRecord(body)) return null;
|
|
80
|
-
const actual = Object.keys(body).sort();
|
|
81
|
-
const expected = [...keys].sort();
|
|
82
|
-
return actual.length === expected.length && actual.every((key, index) => key === expected[index]) ? body : null;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function port(value: unknown): value is number {
|
|
86
|
-
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** A GUI session redeemed from an operator-created, one-use pairing grant. */
|
|
90
|
-
function pairedSession(ctx: ManagementContext): boolean {
|
|
91
|
-
return ctx.principal === "gui-session"
|
|
92
|
-
&& ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* A paired session, or on a standalone runtime the current loopback-issued session that reached
|
|
97
|
-
* the public listener bound to a loopback hostname. The loopback bootstrap mints that session
|
|
98
|
-
* without a credential, so this is casual-path protection like POST /api/github/star, not a
|
|
99
|
-
* secret-backed boundary like the admin token. Hubs keep the paired-only rule. Durable operations
|
|
100
|
-
* that require operator approval, such as joining this machine as a Child, use pairedSession.
|
|
101
|
-
*/
|
|
102
|
-
function dashboardSession(ctx: ManagementContext): boolean {
|
|
103
|
-
if (pairedSession(ctx)) return true;
|
|
104
|
-
return ctx.principal === "gui-session"
|
|
105
|
-
&& (ctx.config.runtimeRole ?? "standalone") === "standalone"
|
|
106
|
-
&& ctx.guiSessionIssuance === "loopback"
|
|
107
|
-
&& ctx.trustedLoopbackIngress
|
|
108
|
-
&& ctx.sessionControl?.isCurrent(ctx.req, ctx.config) === true;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function adminLoopback(ctx: ManagementContext): boolean {
|
|
112
|
-
return ctx.principal === "admin-token" && ctx.trustedLoopbackIngress;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function auth(ctx: ManagementContext, kind: "dashboard" | "paired" | "admin" | "either"): Response | null {
|
|
116
|
-
if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403);
|
|
117
|
-
const allowed = kind === "dashboard" ? dashboardSession(ctx)
|
|
118
|
-
: kind === "paired" ? pairedSession(ctx)
|
|
119
|
-
: kind === "admin" ? adminLoopback(ctx)
|
|
120
|
-
: dashboardSession(ctx) || adminLoopback(ctx);
|
|
121
|
-
return allowed ? null : fail("forbidden", "The required link authorization was not present.", 403);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
27
|
/**
|
|
125
28
|
* The client runtime a join restarts into binds exactly the configured port, with no fallback.
|
|
126
29
|
* A standalone that runs elsewhere (`ocx start --port`, or a port fallback) would restart into a
|
|
@@ -141,18 +44,6 @@ function joinDenied(ctx: ManagementContext): LinkJoinDenied | null {
|
|
|
141
44
|
return null;
|
|
142
45
|
}
|
|
143
46
|
|
|
144
|
-
function runnerFor(ctx: ManagementContext): SshRunner {
|
|
145
|
-
return ctx.deps.sshRunner ?? createSshRunner();
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function readStoreFor(ctx: ManagementContext): LinkStore {
|
|
149
|
-
return ctx.deps.readLinkStore?.() ?? readLinkStore(linkStorePath());
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function writeStoreFor(ctx: ManagementContext, store: LinkStore): void {
|
|
153
|
-
(ctx.deps.writeLinkStore ?? (value => writeLinkStore(linkStorePath(), value)))(store);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
47
|
function stateFor(ctx: ManagementContext): LinkRouteState | null {
|
|
157
48
|
const supervisor = ctx.deps.linkSupervisor?.();
|
|
158
49
|
const listener = ctx.deps.linkListener?.();
|
|
@@ -171,246 +62,11 @@ function stateFor(ctx: ManagementContext): LinkRouteState | null {
|
|
|
171
62
|
return state;
|
|
172
63
|
}
|
|
173
64
|
|
|
174
|
-
function knownHostsFile(ctx: ManagementContext): string {
|
|
175
|
-
return ctx.deps.linkKnownHostsPath?.() ?? linkKnownHostsPath();
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function putKnownHost(path: string, alias: string, line: string): string {
|
|
179
|
-
const before = (() => { try { return readFileSync(path, "utf8"); } catch { return ""; } })();
|
|
180
|
-
const lines = before.split(/\r?\n/).filter(value => value && !value.startsWith(`${alias} `) && !value.startsWith(`${alias},`));
|
|
181
|
-
lines.push(line.trim());
|
|
182
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
183
|
-
writeFileSync(path, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
184
|
-
try { chmodSync(path, 0o600); } catch { /* best effort on Windows */ }
|
|
185
|
-
return before;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function restoreKnownHost(path: string, before: string): void {
|
|
189
|
-
if (!before) {
|
|
190
|
-
try { rmSync(path, { force: true }); } catch { /* best effort compensation */ }
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
writeFileSync(path, before, { mode: 0o600 });
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function issueKey(ctx: ManagementContext, name: string): IssuedApiKey {
|
|
197
|
-
return (ctx.deps.issueApiKey ?? issueApiKeyInProcess)(ctx.config, name);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function revokeKey(ctx: ManagementContext, id: string): boolean {
|
|
201
|
-
return (ctx.deps.revokeApiKey ?? revokeApiKeyInProcess)(ctx.config, id);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Revocation that a retry can repeat. A key that is no longer in the live config is already
|
|
206
|
-
* revoked, which is exactly the state a partially completed cleanup leaves behind; without this
|
|
207
|
-
* a retried DELETE would fail forever on the key it removed the first time.
|
|
208
|
-
*/
|
|
209
|
-
function revokeKeyIdempotent(ctx: ManagementContext, id: string): boolean {
|
|
210
|
-
let revoked = false;
|
|
211
|
-
try { revoked = revokeKey(ctx, id); } catch { revoked = false; }
|
|
212
|
-
if (revoked) return true;
|
|
213
|
-
return !(ctx.config.apiKeys ?? []).some(entry => entry.id === id);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function compensationFailure(ctx: ManagementContext, state: LinkRouteState, record: LinkStore["links"][number], phase: string): Response {
|
|
217
|
-
const since = new Date((ctx.deps.now ?? Date.now)()).toISOString();
|
|
218
|
-
(state.compensationFailures ??= new Map()).set(record.id, { since, reason: "compensation_failed" });
|
|
219
|
-
try { markCompensationFailed(record.id, since, compensationPath()); } catch (error) {
|
|
220
|
-
console.warn(`[link] compensation marker persistence failed linkId=${record.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
221
|
-
}
|
|
222
|
-
console.warn(`[link] compensation_failed linkId=${record.id} phase=${phase}`);
|
|
223
|
-
return fail("compensation_failed", "The link compensation could not be completed.", 500);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async function compensateNewLink(ctx: ManagementContext, state: LinkRouteState, record: LinkStore["links"][number]): Promise<Response | null> {
|
|
227
|
-
const revoked = revokeKeyIdempotent(ctx, record.apiKeyId);
|
|
228
|
-
if (!revoked) {
|
|
229
|
-
try { await state.supervisor.stopLink(record.id); } catch { /* retain the record and failure marker */ }
|
|
230
|
-
try {
|
|
231
|
-
const current = readStoreFor(ctx);
|
|
232
|
-
if (!current.links.some(link => link.id === record.id)) {
|
|
233
|
-
writeStoreFor(ctx, { ...current, links: [...current.links, record] });
|
|
234
|
-
}
|
|
235
|
-
} catch {
|
|
236
|
-
// The in-memory marker still makes the residual visible when persistence is unavailable.
|
|
237
|
-
}
|
|
238
|
-
return compensationFailure(ctx, state, record, "revoke");
|
|
239
|
-
}
|
|
240
|
-
try { await state.supervisor.stopLink(record.id); } catch { return compensationFailure(ctx, state, record, "stop"); }
|
|
241
|
-
try {
|
|
242
|
-
const current = readStoreFor(ctx);
|
|
243
|
-
const next = { ...current, links: current.links.filter(link => link.id !== record.id) };
|
|
244
|
-
if (next.links.length !== current.links.length) {
|
|
245
|
-
writeStoreFor(ctx, next);
|
|
246
|
-
}
|
|
247
|
-
clearCompensationFailed(record.id, compensationPath());
|
|
248
|
-
state.compensationFailures?.delete(record.id);
|
|
249
|
-
if (next.links.length === 0) await state.listener.close();
|
|
250
|
-
return null;
|
|
251
|
-
} catch {
|
|
252
|
-
return compensationFailure(ctx, state, record, "store");
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
65
|
async function candidates(ctx: ManagementContext): Promise<Response> {
|
|
257
66
|
const values = ctx.deps.loadLinkCandidates?.() ?? loadHostCandidates();
|
|
258
67
|
return Response.json({ candidates: values.map(value => ({ alias: value.alias, source: value.source })) });
|
|
259
68
|
}
|
|
260
69
|
|
|
261
|
-
async function probe(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
262
|
-
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias"]);
|
|
263
|
-
if (!body || typeof body.alias !== "string") return fail("invalid_body", "alias is required.", 400);
|
|
264
|
-
try { assertSshAlias(body.alias); } catch { return fail("invalid_alias", "alias must be a valid SSH host alias.", 400); }
|
|
265
|
-
const dir = mkdtempSync(join(tmpdir(), "ocx-link-probe-"));
|
|
266
|
-
const tempKnownHosts = join(dir, "known_hosts");
|
|
267
|
-
writeFileSync(tempKnownHosts, "", { mode: 0o600 });
|
|
268
|
-
const runner = runnerFor(ctx);
|
|
269
|
-
try {
|
|
270
|
-
const result = await runner.run(buildProbeArgv({ alias: body.alias, tempKnownHostsFile: tempKnownHosts }), { timeoutMs: 30_000 });
|
|
271
|
-
if (result.code !== 0) return fail("probe_failed", "SSH host probing failed.", 502, sshFailureHint(result.stderr));
|
|
272
|
-
const fingerprintResult = await runner.run(buildFingerprintArgv(tempKnownHosts), { timeoutMs: 10_000 });
|
|
273
|
-
if (fingerprintResult.code !== 0) return fail("fingerprint_failed", "The SSH host fingerprint could not be read.", 502, sshFailureHint(fingerprintResult.stderr));
|
|
274
|
-
const parsed = parseFingerprintLine(fingerprintResult.stdout.trim());
|
|
275
|
-
const knownHostLine = readFileSync(tempKnownHosts, "utf8").trim().split(/\r?\n/).filter(Boolean).at(-1);
|
|
276
|
-
if (!knownHostLine) return fail("fingerprint_failed", "The SSH probe did not record a host key.", 502);
|
|
277
|
-
state.pendingHosts.set(body.alias, { alias: body.alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType, knownHostLine, probedAt: (ctx.deps.now ?? Date.now)() });
|
|
278
|
-
return Response.json({ alias: body.alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType });
|
|
279
|
-
} catch (error) {
|
|
280
|
-
return fail("probe_failed", "SSH host probing failed.", 502, sshRunnerErrorHint(error));
|
|
281
|
-
} finally {
|
|
282
|
-
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
async function confirmHost(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
287
|
-
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias", "fingerprint"]);
|
|
288
|
-
if (!body || typeof body.alias !== "string" || typeof body.fingerprint !== "string") return fail("invalid_body", "alias and fingerprint are required.", 400);
|
|
289
|
-
const pending = state.pendingHosts.get(body.alias);
|
|
290
|
-
const now = (ctx.deps.now ?? Date.now)();
|
|
291
|
-
if (!pending || now - pending.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host probe has expired.", 409);
|
|
292
|
-
if (pending.fingerprint !== body.fingerprint) return fail("host_fingerprint_mismatch", "The confirmed fingerprint does not match the probe.", 409);
|
|
293
|
-
const path = knownHostsFile(ctx);
|
|
294
|
-
const before = putKnownHost(path, pending.alias, pending.knownHostLine);
|
|
295
|
-
// Every refusal restores known_hosts and keeps the pending probe, so a retry within its TTL works.
|
|
296
|
-
const refuse = (response: Response): Response => { restoreKnownHost(path, before); return response; };
|
|
297
|
-
let result: SshRunResult;
|
|
298
|
-
try {
|
|
299
|
-
result = await runnerFor(ctx).run(buildExecArgv({ alias: pending.alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
|
|
300
|
-
} catch (error) {
|
|
301
|
-
return refuse(fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshRunnerErrorHint(error)));
|
|
302
|
-
}
|
|
303
|
-
if (result.code === REMOTE_COMMAND_NOT_FOUND) return refuse(fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr)));
|
|
304
|
-
if (result.code !== 0) return refuse(fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshFailureHint(result.stderr)));
|
|
305
|
-
const version = parseRemoteOcxVersion(result.stdout);
|
|
306
|
-
if (!version) return refuse(fail("remote_ocx_unrecognized", "The remote ocx did not report an OpenCodex version.", 502));
|
|
307
|
-
if (!meetsRemoteOcxFloor(version.parts)) {
|
|
308
|
-
return refuse(fail("remote_ocx_outdated", `The remote OpenCodex is older than ${MIN_REMOTE_OCX.join(".")}.`, 409, boundHint(`opencodex ${version.version}`)));
|
|
309
|
-
}
|
|
310
|
-
const confirmed = { ...pending, ocxVersion: version.version };
|
|
311
|
-
state.confirmedHosts ??= new Map();
|
|
312
|
-
state.confirmedHosts.set(pending.alias, confirmed);
|
|
313
|
-
state.pendingHosts.delete(pending.alias);
|
|
314
|
-
return Response.json({ alias: pending.alias, fingerprint: pending.fingerprint, ocxVersion: confirmed.ocxVersion });
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* `ocx --version` prints `opencodex <version>` first; a usage banner, an unbounded version tail or
|
|
319
|
-
* other output is refused.
|
|
320
|
-
*/
|
|
321
|
-
function parseRemoteOcxVersion(stdout: string): { version: string; parts: [number, number, number] } | null {
|
|
322
|
-
const match = REMOTE_OCX_VERSION.exec(stdout.trim().split(/\r?\n/)[0] ?? "");
|
|
323
|
-
if (!match) return null;
|
|
324
|
-
return { version: `${match[1]}.${match[2]}.${match[3]}${match[4] ?? ""}${match[5] ?? ""}`, parts: [Number(match[1]), Number(match[2]), Number(match[3])] };
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function meetsRemoteOcxFloor(parts: readonly [number, number, number]): boolean {
|
|
328
|
-
for (let index = 0; index < MIN_REMOTE_OCX.length; index += 1) {
|
|
329
|
-
if (parts[index] !== MIN_REMOTE_OCX[index]) return parts[index]! > MIN_REMOTE_OCX[index]!;
|
|
330
|
-
}
|
|
331
|
-
return true;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function parsePortOutput(stdout: string): number | null {
|
|
335
|
-
try {
|
|
336
|
-
const value: unknown = JSON.parse(stdout.trim());
|
|
337
|
-
if (!isRecord(value) || Object.keys(value).length !== 1 || !port(value.port)) return null;
|
|
338
|
-
return value.port;
|
|
339
|
-
} catch { return null; }
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
343
|
-
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias"]);
|
|
344
|
-
if (!body || typeof body.alias !== "string") return fail("invalid_body", "alias is required.", 400);
|
|
345
|
-
const confirmed = state.confirmedHosts?.get(body.alias);
|
|
346
|
-
if (!confirmed) return fail("host_not_confirmed", "Confirm the SSH host before applying the link.", 409);
|
|
347
|
-
const knownHosts = knownHostsFile(ctx);
|
|
348
|
-
const runner = runnerFor(ctx);
|
|
349
|
-
let remotePort: number;
|
|
350
|
-
let portHint: string | undefined;
|
|
351
|
-
try {
|
|
352
|
-
const result = await runner.run(buildExecArgv({ alias: body.alias, argv: remoteOcxArgv(["link", "port"]), knownHostsFile: knownHosts }), { timeoutMs: 30_000 });
|
|
353
|
-
if (result.code === REMOTE_COMMAND_NOT_FOUND) return fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr));
|
|
354
|
-
remotePort = result.code === 0 ? parsePortOutput(result.stdout) ?? 0 : 0;
|
|
355
|
-
if (result.code !== 0) portHint = sshFailureHint(result.stderr);
|
|
356
|
-
} catch (error) { remotePort = 0; portHint = sshRunnerErrorHint(error); }
|
|
357
|
-
if (!port(remotePort)) return fail("remote_port_failed", "The remote link port could not be determined.", 502, portHint);
|
|
358
|
-
let issued: IssuedApiKey;
|
|
359
|
-
try { issued = issueKey(ctx, `link:${body.alias}`); }
|
|
360
|
-
catch { return fail("key_issue_failed", "The link key could not be issued.", 503); }
|
|
361
|
-
const id = newLinkId();
|
|
362
|
-
const record = { id, alias: body.alias, direction: "hub-initiated" as const, hostKeyFingerprint: confirmed.fingerprint, tunnelPort: remotePort, apiKeyId: issued.id, createdAt: new Date((ctx.deps.now ?? Date.now)()).toISOString() };
|
|
363
|
-
let store: LinkStore;
|
|
364
|
-
try {
|
|
365
|
-
const current = readStoreFor(ctx);
|
|
366
|
-
if (current.links.some(link => link.alias === body.alias && link.direction === "hub-initiated")) {
|
|
367
|
-
const compensation = await compensateNewLink(ctx, state, record);
|
|
368
|
-
if (compensation) return compensation;
|
|
369
|
-
return fail("link_exists", "A link for this alias already exists.", 409);
|
|
370
|
-
}
|
|
371
|
-
store = { ...current, links: [...current.links, record] };
|
|
372
|
-
writeStoreFor(ctx, store);
|
|
373
|
-
await state.listener.ensureStarted();
|
|
374
|
-
if (state.listener.status().state !== "listening") {
|
|
375
|
-
const compensation = await compensateNewLink(ctx, state, record);
|
|
376
|
-
return compensation ?? fail("listener_unavailable", "The link listener is unavailable.", 503);
|
|
377
|
-
}
|
|
378
|
-
await state.supervisor.ensureStarted();
|
|
379
|
-
await state.supervisor.reload();
|
|
380
|
-
} catch {
|
|
381
|
-
const compensation = await compensateNewLink(ctx, state, record);
|
|
382
|
-
if (compensation) return compensation;
|
|
383
|
-
return fail("link_apply_failed", "The link could not be started.", 503);
|
|
384
|
-
}
|
|
385
|
-
const admission = awaitFirstAdmission(issued.id, APPLY_ADMISSION_TIMEOUT_MS, state.listener.onAuthenticatedCatalog);
|
|
386
|
-
const input = new TextEncoder().encode(JSON.stringify({ apiKeyId: issued.id, key: issued.key }));
|
|
387
|
-
try {
|
|
388
|
-
const result = await runner.run(buildExecArgv({ alias: body.alias, argv: remoteOcxArgv(["connect", "--link", "--key-stdin", "--tunnel-port", String(remotePort), "--link-id", id]), knownHostsFile: knownHosts }), { stdin: input, timeoutMs: APPLY_ADMISSION_TIMEOUT_MS });
|
|
389
|
-
if (result.code !== 0) {
|
|
390
|
-
void admission.catch(() => {});
|
|
391
|
-
const compensation = await compensateNewLink(ctx, state, record);
|
|
392
|
-
if (compensation) return compensation;
|
|
393
|
-
const hint = sshFailureHint(result.stderr);
|
|
394
|
-
return result.code === REMOTE_COMMAND_NOT_FOUND
|
|
395
|
-
? fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, hint)
|
|
396
|
-
: fail("remote_connect_failed", "The remote link connection failed.", 502, hint);
|
|
397
|
-
}
|
|
398
|
-
try {
|
|
399
|
-
await admission;
|
|
400
|
-
} catch {
|
|
401
|
-
const compensation = await compensateNewLink(ctx, state, record);
|
|
402
|
-
return compensation ?? fail("admission_timeout", "The remote link did not authenticate a catalog request in time.", 502);
|
|
403
|
-
}
|
|
404
|
-
return Response.json({ linkId: id }, { status: 202 });
|
|
405
|
-
} catch (error) {
|
|
406
|
-
void admission.catch(() => {});
|
|
407
|
-
const compensation = await compensateNewLink(ctx, state, record);
|
|
408
|
-
return compensation ?? fail("remote_connect_failed", "The remote link connection failed.", 502, sshRunnerErrorHint(error));
|
|
409
|
-
} finally {
|
|
410
|
-
input.fill(0);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
70
|
async function issue(ctx: ManagementContext): Promise<Response> {
|
|
415
71
|
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias", "tunnelPort"]);
|
|
416
72
|
if (!body || typeof body.alias !== "string" || !isLinkPort(body.tunnelPort)) return fail("invalid_body", "alias and tunnelPort are required.", 400);
|
|
@@ -430,13 +86,18 @@ async function issue(ctx: ManagementContext): Promise<Response> {
|
|
|
430
86
|
const record = { id, alias: body.alias, direction: "client-initiated" as const, hostKeyFingerprint: null, tunnelPort: body.tunnelPort, apiKeyId: issued.id, createdAt: new Date((ctx.deps.now ?? Date.now)()).toISOString() };
|
|
431
87
|
let failureCode = "link_issue_failed";
|
|
432
88
|
try {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
89
|
+
// Same atomic duplicate-check+append as the hub-initiated branch.
|
|
90
|
+
const writeOutcome = withConfigMutationLockSync((): "dup" | "written" => {
|
|
91
|
+
const current = readStoreFor(ctx);
|
|
92
|
+
if (current.links.some(link => link.id === id)) throw new Error("link id collision");
|
|
93
|
+
if (current.links.some(link => link.alias === body.alias && link.direction === "client-initiated")) return "dup";
|
|
94
|
+
writeStoreFor(ctx, { ...current, links: [...current.links, record] });
|
|
95
|
+
return "written";
|
|
96
|
+
});
|
|
97
|
+
if (writeOutcome === "dup") {
|
|
436
98
|
const compensation = await compensateNewLink(ctx, state, record);
|
|
437
99
|
return compensation ?? fail("link_exists", "A link for this alias already exists.", 409);
|
|
438
100
|
}
|
|
439
|
-
writeStoreFor(ctx, { ...current, links: [...current.links, record] });
|
|
440
101
|
await state.listener.ensureStarted();
|
|
441
102
|
if (state.listener.status().state !== "listening") {
|
|
442
103
|
failureCode = "listener_unavailable";
|
|
@@ -506,6 +167,30 @@ async function handleJoin(ctx: ManagementContext, state: LinkRouteState): Promis
|
|
|
506
167
|
}
|
|
507
168
|
}
|
|
508
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Restart a Home-initiated link's tunnel now: the dashboard's Retry and `ocx link reconnect`.
|
|
172
|
+
* The supervisor stops the current ssh child, if any, forgets its backoff or failed state and
|
|
173
|
+
* spawns a fresh attempt; status then reports it like any other attempt.
|
|
174
|
+
*/
|
|
175
|
+
async function reconnect(ctx: ManagementContext, state: LinkRouteState, id: string): Promise<Response> {
|
|
176
|
+
if (!LINK_ID.test(id)) return fail("invalid_link_id", "The link id is invalid.", 400);
|
|
177
|
+
const record = readStoreFor(ctx).links.find(link => link.id === id);
|
|
178
|
+
if (!record) return fail("link_not_found", "The link was not found.", 404);
|
|
179
|
+
if (record.direction !== "hub-initiated") {
|
|
180
|
+
return fail("link_not_home_initiated", "Only a Home-initiated link has a tunnel this computer can restart.", 409);
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
// A reverse forward may only target a port the link listener owns, as when a link is applied.
|
|
184
|
+
await state.listener.ensureStarted();
|
|
185
|
+
if (state.listener.status().state !== "listening") return fail("listener_unavailable", "The link listener is unavailable.", 503);
|
|
186
|
+
await state.supervisor.ensureStarted();
|
|
187
|
+
if (!await state.supervisor.reconnect(id)) return fail("link_unavailable", "The link tunnel could not be restarted.", 503);
|
|
188
|
+
} catch {
|
|
189
|
+
return fail("link_unavailable", "The link tunnel could not be restarted.", 503);
|
|
190
|
+
}
|
|
191
|
+
return Response.json({ linkId: id });
|
|
192
|
+
}
|
|
193
|
+
|
|
509
194
|
async function remove(ctx: ManagementContext, state: LinkRouteState, id: string): Promise<Response> {
|
|
510
195
|
if (!LINK_ID.test(id)) return fail("invalid_link_id", "The link id is invalid.", 400);
|
|
511
196
|
const current = readStoreFor(ctx);
|
|
@@ -514,38 +199,46 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string)
|
|
|
514
199
|
const body = await readManagementJsonBodyOr(ctx.req, {});
|
|
515
200
|
if (!isRecord(body) || Object.keys(body).some(key => key !== "force") || (body.force !== undefined && typeof body.force !== "boolean")) return fail("invalid_body", "force must be a boolean.", 400);
|
|
516
201
|
const force = body.force === true;
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
202
|
+
const release = state.supervisor.holdLink?.(id);
|
|
203
|
+
try {
|
|
204
|
+
await state.supervisor.stopLink(id);
|
|
205
|
+
const restartTunnel = async (): Promise<void> => {
|
|
206
|
+
release?.();
|
|
207
|
+
try {
|
|
208
|
+
await state.supervisor.ensureStarted();
|
|
209
|
+
await state.supervisor.reload();
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.warn(`[link] tunnel restart failed linkId=${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
if (!force && record.direction === "hub-initiated") {
|
|
215
|
+
try {
|
|
216
|
+
const result = await runnerFor(ctx).run(buildExecArgv({ alias: record.alias, argv: remoteOcxArgv(["disconnect"]), knownHostsFile: knownHostsFile(ctx) }), { timeoutMs: 30_000 });
|
|
217
|
+
if (result.code !== 0) {
|
|
218
|
+
await restartTunnel();
|
|
219
|
+
return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshFailureHint(result.stderr));
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
530
222
|
await restartTunnel();
|
|
531
|
-
return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502,
|
|
223
|
+
return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshRunnerErrorHint(error));
|
|
532
224
|
}
|
|
533
|
-
} catch (error) {
|
|
534
|
-
await restartTunnel();
|
|
535
|
-
return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshRunnerErrorHint(error));
|
|
536
225
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
226
|
+
if (!revokeKeyIdempotent(ctx, record.apiKeyId)) return fail("key_revoke_failed", "The link key could not be revoked.", 502);
|
|
227
|
+
let next: LinkStore;
|
|
228
|
+
try {
|
|
229
|
+
next = withConfigMutationLockSync(() => {
|
|
230
|
+
const latest = readStoreFor(ctx);
|
|
231
|
+
const filtered = { ...latest, links: latest.links.filter(link => link.id !== id) };
|
|
232
|
+
if (filtered.links.length !== latest.links.length) writeStoreFor(ctx, filtered);
|
|
233
|
+
return filtered;
|
|
234
|
+
});
|
|
235
|
+
clearCompensationFailed(id, compensationPath());
|
|
236
|
+
} catch { return fail("link_remove_failed", "The link record could not be removed.", 503); }
|
|
237
|
+
state.compensationFailures?.delete(id);
|
|
238
|
+
await state.supervisor.reload();
|
|
239
|
+
if (next.links.length === 0) await state.listener.close();
|
|
240
|
+
return Response.json({ linkId: id });
|
|
241
|
+
} finally { release?.(); }
|
|
549
242
|
}
|
|
550
243
|
|
|
551
244
|
export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: LinkRouteState): Promise<Response | null> {
|
|
@@ -574,6 +267,7 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
|
|
|
574
267
|
const dto: LinkStatusDto = projectLinkStatus(
|
|
575
268
|
store, state.supervisor.status(), listenerStatus, ctx.config, readCompensation(),
|
|
576
269
|
ctx.config.runtimeRole === "client" ? clientLinkTunnelStatus() : null,
|
|
270
|
+
ctx.config.runtimeRole === "client" && ctx.config.client?.link ? clientLinkStatusIdentity(ctx.config.client.link.linkId) : null,
|
|
577
271
|
);
|
|
578
272
|
for (const link of dto.links) {
|
|
579
273
|
const failure = state.compensationFailures?.get(link.id);
|
|
@@ -596,17 +290,17 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
|
|
|
596
290
|
return candidates(ctx);
|
|
597
291
|
}
|
|
598
292
|
if (url.pathname === "/api/link/probe" && req.method === "POST") {
|
|
599
|
-
const denied = auth(ctx, "
|
|
293
|
+
const denied = auth(ctx, "either");
|
|
600
294
|
if (denied) return denied;
|
|
601
295
|
return probe(ctx, state);
|
|
602
296
|
}
|
|
603
297
|
if (url.pathname === "/api/link/confirm-host" && req.method === "POST") {
|
|
604
|
-
const denied = auth(ctx, "
|
|
298
|
+
const denied = auth(ctx, "either");
|
|
605
299
|
if (denied) return denied;
|
|
606
300
|
return confirmHost(ctx, state);
|
|
607
301
|
}
|
|
608
302
|
if (url.pathname === "/api/link/apply" && req.method === "POST") {
|
|
609
|
-
const denied = auth(ctx, "
|
|
303
|
+
const denied = auth(ctx, "either");
|
|
610
304
|
if (denied) return denied;
|
|
611
305
|
return apply(ctx, state);
|
|
612
306
|
}
|
|
@@ -615,6 +309,14 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
|
|
|
615
309
|
if (denied) return denied;
|
|
616
310
|
return issue(ctx);
|
|
617
311
|
}
|
|
312
|
+
const reconnectPath = req.method === "POST" ? /^\/api\/link\/([^/]+)\/reconnect$/.exec(path) : null;
|
|
313
|
+
if (reconnectPath) {
|
|
314
|
+
const denied = auth(ctx, "either");
|
|
315
|
+
if (denied) return denied;
|
|
316
|
+
let id: string;
|
|
317
|
+
try { id = decodeURIComponent(reconnectPath[1]!); } catch { return fail("invalid_link_id", "The link id is invalid.", 400); }
|
|
318
|
+
return reconnect(ctx, state, id);
|
|
319
|
+
}
|
|
618
320
|
if (req.method === "DELETE" && path.startsWith("/api/link/")) {
|
|
619
321
|
const denied = auth(ctx, "either");
|
|
620
322
|
if (denied) return denied;
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { CatalogModel } from "../../codex/catalog";
|
|
12
12
|
import { antigravityFamilyDisabled } from "../../providers/antigravity-effort-families";
|
|
13
13
|
import { observeModelCacheRevision } from "../../codex/model-cache";
|
|
14
|
+
import { isPassiveRead } from "../../lib/passive-read";
|
|
14
15
|
import {
|
|
15
16
|
captureExportConfigAdmission,
|
|
16
17
|
detachedConfigSnapshot,
|
|
@@ -306,8 +307,9 @@ export async function listManagementModelRows(
|
|
|
306
307
|
* that brings its own roster still sees exactly what a writer would, and the two cannot
|
|
307
308
|
* disagree about the roster for any reason except the roster itself.
|
|
308
309
|
*/
|
|
310
|
+
// A passive read (#6959) also skips the entitlement refresh, which reaches the Codex accounts.
|
|
309
311
|
const models = options.models === undefined
|
|
310
|
-
? (await Promise.all([
|
|
312
|
+
? isPassiveRead() ? [] : (await Promise.all([
|
|
311
313
|
fetchAllModels(config, options.providerContentRevisions, projection => { config = projection; }),
|
|
312
314
|
ensureCodexEntitlementFreshness(config, {
|
|
313
315
|
waitMs: options.entitlementWaitMs ?? 3_000,
|
|
@@ -50,6 +50,7 @@ const RETRY_POLICY_VALIDATORS = {
|
|
|
50
50
|
*/
|
|
51
51
|
export const PROVIDER_COMPAT_CARRY_FIELDS = [
|
|
52
52
|
"preserveReasoningContentModels",
|
|
53
|
+
"preserveResponsesReasoningContent",
|
|
53
54
|
"requiresReasoningPlaceholderModels",
|
|
54
55
|
"foldDeveloperRoleToSystem",
|
|
55
56
|
"reasoningWireFormat",
|
|
@@ -160,6 +161,8 @@ export function providerCompatFieldConfigError(provider: Record<string, unknown>
|
|
|
160
161
|
if (fold !== undefined && typeof fold !== "boolean") return "foldDeveloperRoleToSystem must be a boolean";
|
|
161
162
|
const hideRawReasoning = provider.hideRawReasoning;
|
|
162
163
|
if (hideRawReasoning !== undefined && typeof hideRawReasoning !== "boolean") return "hideRawReasoning must be a boolean";
|
|
164
|
+
const preserveResponses = provider.preserveResponsesReasoningContent;
|
|
165
|
+
if (preserveResponses !== undefined && typeof preserveResponses !== "boolean") return "preserveResponsesReasoningContent must be a boolean";
|
|
163
166
|
const wire = provider.reasoningWireFormat;
|
|
164
167
|
if (wire !== undefined && !PROVIDER_REASONING_WIRE_FORMATS.includes(wire as never)) {
|
|
165
168
|
return `reasoningWireFormat must be one of: ${PROVIDER_REASONING_WIRE_FORMATS.join(", ")}`;
|
|
@@ -204,6 +207,13 @@ export function applyProviderCompatPatchFields(
|
|
|
204
207
|
else return { error: "hideRawReasoning must be a boolean" };
|
|
205
208
|
touched = true;
|
|
206
209
|
}
|
|
210
|
+
if (Object.hasOwn(rawBody, "preserveResponsesReasoningContent")) {
|
|
211
|
+
const value = rawBody.preserveResponsesReasoningContent;
|
|
212
|
+
if (value === null) delete next.preserveResponsesReasoningContent;
|
|
213
|
+
else if (typeof value === "boolean") next.preserveResponsesReasoningContent = value;
|
|
214
|
+
else return { error: "preserveResponsesReasoningContent must be a boolean" };
|
|
215
|
+
touched = true;
|
|
216
|
+
}
|
|
207
217
|
if (Object.hasOwn(rawBody, "reasoningWireFormat")) {
|
|
208
218
|
const value = rawBody.reasoningWireFormat;
|
|
209
219
|
if (value === null) {
|
|
@@ -917,6 +917,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
917
917
|
// Presence only (#959 review): header names and values never leave the process.
|
|
918
918
|
hasHeaders: !!p.headers && Object.keys(p.headers).length > 0,
|
|
919
919
|
allowPrivateNetwork: p.allowPrivateNetwork === true,
|
|
920
|
+
preserveResponsesReasoningContent: p.preserveResponsesReasoningContent,
|
|
920
921
|
liveModels: p.liveModels !== false,
|
|
921
922
|
requestPacing: p.requestPacing,
|
|
922
923
|
models: p.models ?? [],
|