@bitkyc08/opencodex 2.83.0 → 2.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -4
- package/bin/ocx.mjs +61 -3
- package/gui/dist/assets/App-BjlttVLy.js +58 -0
- package/gui/dist/assets/Tray-DyR9yBee.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-BMssP4k3.js +94 -0
- package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -3
- package/src/adapters/anthropic/beta-allowlist.ts +87 -8
- package/src/adapters/anthropic/client-identity.ts +1 -1
- package/src/adapters/anthropic/passthrough.ts +33 -4
- package/src/adapters/azure.ts +19 -1
- package/src/adapters/client-fingerprint.ts +24 -0
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/coding-agent/turn.ts +22 -9
- package/src/adapters/cursor/arg-codec.ts +17 -5
- package/src/adapters/cursor/catalog.ts +8 -0
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/cursor/freeform-args.ts +75 -0
- package/src/adapters/cursor/protobuf-events.ts +36 -23
- package/src/adapters/google-antigravity-wire.ts +22 -1
- package/src/adapters/google.ts +2 -2
- package/src/adapters/ollama-native.ts +29 -1
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-chat/response-events.ts +59 -1
- package/src/adapters/openai-chat.ts +8 -5
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +3 -0
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +56 -15
- package/src/advisor/consult.ts +220 -0
- package/src/advisor/context.ts +226 -0
- package/src/advisor/disclosure.ts +20 -0
- package/src/advisor/runtime.ts +305 -0
- package/src/advisor/settings.ts +135 -0
- package/src/advisor/state.ts +369 -0
- package/src/advisor/synthetic-tool.ts +33 -0
- package/src/bridge/errors.ts +24 -0
- package/src/bridge.ts +1 -1
- package/src/chat/inbound.ts +30 -6
- package/src/chat/outbound.ts +5 -0
- package/src/claude/context-windows.ts +54 -1
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +22 -8
- package/src/claude/desktop-picker-profile.ts +11 -4
- package/src/claude/desktop-profile.ts +10 -1
- package/src/claude/desktop-remote-store-io.ts +5 -3
- package/src/claude/inbound.ts +121 -50
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/local-ca.ts +8 -1
- 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/claude/model-default-settings.ts +154 -0
- package/src/claude/model-info.ts +7 -7
- package/src/claude/outbound.ts +13 -6
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +26 -2
- package/src/cli/account.ts +41 -5
- package/src/cli/advisor.ts +151 -0
- package/src/cli/auxiliary-listener-preflight.ts +37 -0
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-agents-routing.ts +18 -0
- package/src/cli/capabilities-base.ts +14 -2
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/chatgpt-command.ts +35 -14
- package/src/cli/claude.ts +60 -11
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +21 -4
- package/src/cli/doctor.ts +9 -0
- package/src/cli/ensure-desktop-cold-start.ts +329 -0
- package/src/cli/export-command.ts +15 -2
- package/src/cli/help.ts +2 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +26 -8
- package/src/cli/integration-plan-dto.ts +2 -0
- 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 +20 -7
- package/src/cli/restart-scope.ts +3 -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/contracts.ts +1 -0
- package/src/clients/config-export/model-metadata.ts +3 -2
- package/src/clients/config-export/qoder.ts +93 -0
- package/src/clients/config-export.ts +59 -16
- 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 +5 -2
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/auth-context.ts +79 -5
- package/src/codex/autostart-health.ts +7 -1
- package/src/codex/catalog/build-entries.ts +11 -2
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/configured-tiers.ts +50 -0
- package/src/codex/catalog/effort.ts +6 -3
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/model-hints.ts +21 -3
- package/src/codex/catalog/native-models.ts +16 -2
- package/src/codex/catalog/native-ultrafast.ts +106 -0
- package/src/codex/catalog/parsing.ts +5 -0
- package/src/codex/catalog/provider-models.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +2 -0
- package/src/codex/catalog/routed-gather.ts +22 -4
- package/src/codex/catalog/vendor-context.ts +82 -0
- package/src/codex/config-write-lock.ts +40 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desktop-app/types.ts +2 -0
- package/src/codex/desktop-app/windows.ts +29 -16
- package/src/codex/desktop-app-restart.ts +4 -2
- package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
- package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
- package/src/codex/desktop-compatibility/connection-store.ts +87 -0
- package/src/codex/desktop-compatibility/installed-build.ts +55 -0
- package/src/codex/desktop-compatibility/json-body.ts +29 -0
- package/src/codex/desktop-compatibility/native-identity.ts +62 -0
- package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
- package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
- package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
- package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
- package/src/codex/desktop-compatibility/runtime.ts +173 -0
- package/src/codex/desktop-compatibility/service.ts +30 -0
- package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
- package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
- package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
- package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
- package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
- package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
- package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
- package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
- package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
- package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
- package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
- package/src/codex/history-provider.ts +1 -1
- package/src/codex/inject/config-write-section.ts +4 -1
- package/src/codex/inject/remove.ts +18 -6
- package/src/codex/journal.ts +16 -1
- package/src/codex/main-account.ts +99 -15
- package/src/codex/native-main-refresh-mode.ts +217 -0
- 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/health-store.ts +1 -1
- package/src/codex/routing/pool-recovery.ts +79 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-diagnostics.ts +6 -0
- package/src/codex/shim-overlay.ts +15 -6
- 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 +18 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +56 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +8 -2
- package/src/config/paths.ts +13 -3
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +19 -0
- package/src/config/schema/desktop-compatibility.ts +9 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +713 -285
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/reset-coupon-ledger.ts +75 -5
- package/src/grok/reset-coupons.ts +4 -2
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/config-io.ts +20 -2
- package/src/integrations/journal.ts +15 -1
- package/src/integrations/mutation-plan.ts +14 -7
- package/src/integrations/registry.ts +52 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/store.ts +3 -0
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +24 -7
- package/src/lib/advisor-activation.ts +16 -0
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/background-work.ts +65 -0
- package/src/lib/bounded-file-read.ts +58 -0
- package/src/lib/bun-path-runtime.d.mts +10 -1
- package/src/lib/bun-path-runtime.mjs +62 -3
- package/src/lib/desktop-proxy-route.ts +30 -0
- package/src/lib/desktop-upstream-tunnel.ts +107 -0
- package/src/lib/errors.ts +7 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/local-internal-call-capability.ts +55 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/package-tree-integrity.ts +2 -2
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/shadow-call.ts +44 -5
- package/src/lib/socks5-fetch.ts +48 -71
- package/src/lib/socks5-handshake.ts +112 -0
- package/src/lib/translator-budget.ts +7 -1
- 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/generic-account-failover.ts +29 -5
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +28 -17
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro-device-login.ts +2 -1
- package/src/oauth/kiro.ts +55 -6
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +11 -0
- package/src/protocols/codecs/chat-ir.ts +467 -0
- package/src/protocols/codecs/chat.ts +10 -1
- package/src/protocols/codecs/messages-ir.ts +500 -0
- package/src/protocols/features.ts +10 -2
- package/src/protocols/path.ts +11 -1
- package/src/protocols/plan-snapshot.ts +39 -16
- package/src/protocols/plan.ts +9 -1
- package/src/protocols/settings.ts +8 -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-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
- package/src/providers/antigravity-account-roster.ts +102 -0
- package/src/providers/antigravity-models.ts +85 -7
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-rename-fields.ts +2 -0
- package/src/providers/model-rename-migration.ts +5 -3
- package/src/providers/model-rename-startup.ts +5 -3
- 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 +21 -20
- package/src/providers/registry/entries-extended.ts +19 -1
- package/src/providers/registry/model-seeds.ts +17 -0
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +37 -20
- package/src/responses/state.ts +32 -29
- package/src/routing/history/build-file.ts +92 -0
- package/src/routing/history/indexer.ts +248 -216
- package/src/routing/history/ingest.ts +238 -0
- package/src/routing/history/rebuild-job.ts +111 -0
- package/src/routing/history/rebuild-protocol.ts +15 -0
- package/src/routing/history/rebuild-worker.ts +34 -0
- package/src/server/admission-model-scope.ts +4 -1
- package/src/server/antigravity-direct-eligibility.ts +208 -0
- package/src/server/auth-cors.ts +46 -0
- package/src/server/chat-completions.ts +75 -9
- package/src/server/claude-messages.ts +110 -20
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/index/desktop-compatibility-startup.ts +56 -0
- 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 +15 -2
- package/src/server/index/startup-warnings.ts +9 -0
- package/src/server/index/websocket-handler.ts +9 -0
- package/src/server/index.ts +13 -10
- package/src/server/inference/client-encoder-delivery.ts +6 -5
- package/src/server/lifecycle.ts +2 -1
- package/src/server/login-shell-path.ts +146 -0
- package/src/server/management/advisor-routes.ts +191 -0
- package/src/server/management/agent-settings-routes.ts +50 -14
- 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/companion-routes.ts +4 -0
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/desktop-compatibility-routes.ts +46 -0
- package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
- package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
- package/src/server/management/grok-coupon-routes.ts +78 -27
- 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 +82 -402
- package/src/server/management/model-routes.ts +2 -2
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/protocol-settings-patch.ts +1 -0
- 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 +16 -3
- package/src/server/management/shared.ts +3 -0
- package/src/server/management/sibling-guard.ts +1 -0
- package/src/server/management-api.ts +9 -1
- package/src/server/messages-antigravity-direct.ts +195 -0
- package/src/server/messages-native.ts +73 -43
- package/src/server/relay-eager.ts +2 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +63 -6
- package/src/server/request-log.ts +7 -0
- package/src/server/responses/adapter-continuation.ts +35 -18
- package/src/server/responses/adapter-delivery.ts +24 -2
- package/src/server/responses/adapter-dispatch.ts +261 -81
- package/src/server/responses/advisor-plan-slot.ts +27 -0
- package/src/server/responses/advisor-slot.ts +336 -0
- package/src/server/responses/code-mode-collab-surface.ts +313 -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 +126 -48
- 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 +24 -1
- package/src/server/responses/core-combo.ts +28 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-lifetime.ts +2 -0
- package/src/server/responses/core-normalize.ts +11 -0
- package/src/server/responses/core-options.ts +48 -0
- package/src/server/responses/core-replay.ts +7 -4
- package/src/server/responses/core.ts +1 -1
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/passthrough-delivery.ts +20 -11
- package/src/server/responses/passthrough-dispatch.ts +29 -7
- package/src/server/responses/request-prepare.ts +74 -9
- package/src/server/responses/request-transport.ts +22 -8
- package/src/server/responses/run-turn-execution.ts +70 -8
- package/src/server/responses/sidecar-execution.ts +89 -5
- package/src/server/responses/terminal-guard.ts +2 -1
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/responses-undeclared-tool-guard.ts +30 -9
- package/src/server/startup-action-control.ts +38 -4
- package/src/server/startup-health-cache.ts +5 -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 +161 -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 +466 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +71 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/service/logging.ts +72 -0
- package/src/tray/windows-tray.ps1 +43 -24
- package/src/types/config.ts +68 -6
- package/src/types/provider.ts +7 -0
- package/src/types/request.ts +15 -0
- package/src/types/tools.ts +2 -0
- package/src/update/async-check.ts +1 -1
- package/src/update/badge.ts +2 -2
- package/src/update/index.ts +88 -15
- package/src/update/job-supervised-restart.ts +107 -0
- package/src/update/job.ts +32 -32
- package/src/update/notify.ts +1 -1
- package/src/update/refresh-scheduler.ts +2 -2
- package/src/update/supervised-recovery.d.mts +27 -0
- package/src/update/supervised-recovery.mjs +157 -0
- package/src/usage/cache-diagnostic.ts +2 -0
- package/src/usage/expected-prices.ts +16 -0
- package/src/usage/log.ts +19 -1
- package/src/web-search/gemini-executor.ts +2 -2
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/App-Ba8uG696.js +0 -54
- package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
- package/gui/dist/assets/index-BHRZdJuv.js +0 -94
- package/gui/dist/assets/quota-summary-DiJO6sYY.js +0 -1
|
@@ -1,127 +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";
|
|
17
14
|
import { withConfigMutationLockSync } from "../../config/mutation-lock";
|
|
18
15
|
import type { ManagementContext } from "./context";
|
|
19
16
|
import { readManagementJsonBodyOr } from "./body";
|
|
20
|
-
import {
|
|
17
|
+
import { type IssuedApiKey } from "./oauth-account-routes";
|
|
21
18
|
import { acceptSystemRestart, resolveListenPort } from "./system-restart";
|
|
22
19
|
|
|
23
|
-
const PROBE_TTL_MS = 5 * 60_000;
|
|
24
|
-
const APPLY_ADMISSION_TIMEOUT_MS = 15_000;
|
|
25
20
|
const LINK_ID = /^lnk_[0-9a-f]{16}$/;
|
|
26
|
-
// `ocx link` and `ocx connect --link` first shipped in 2.66.0; an older remote passes --version
|
|
27
|
-
// and then fails at apply with remote_port_failed.
|
|
28
|
-
const MIN_REMOTE_OCX = [2, 66, 0] as const;
|
|
29
|
-
// A bounded semver shape: the parsed version reaches the confirm-host body and the outdated hint,
|
|
30
|
-
// so the tail admits only a pre-release and build of semver identifier characters and must end
|
|
31
|
-
// the token. Anything else attached to the version makes the output unrecognized.
|
|
32
|
-
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|$)/;
|
|
33
21
|
const isLinkPath = (path: string): boolean => path === "/api/link" || path.startsWith("/api/link/");
|
|
34
22
|
|
|
35
|
-
export interface PendingLinkHost {
|
|
36
|
-
alias: string;
|
|
37
|
-
fingerprint: string;
|
|
38
|
-
keyType: string;
|
|
39
|
-
knownHostLine: string;
|
|
40
|
-
probedAt: number;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface ConfirmedLinkHost extends PendingLinkHost {
|
|
44
|
-
ocxVersion: string;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface LinkRouteListener {
|
|
48
|
-
ensureStarted(): Promise<void>;
|
|
49
|
-
status(): { state: "off" | "listening" | "failed"; port: number | null; reason: string | null };
|
|
50
|
-
close(): Promise<void>;
|
|
51
|
-
onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface LinkRouteState {
|
|
55
|
-
pendingHosts: Map<string, PendingLinkHost>;
|
|
56
|
-
confirmedHosts?: Map<string, ConfirmedLinkHost>;
|
|
57
|
-
compensationFailures?: Map<string, { since: string; reason: "compensation_failed" }>;
|
|
58
|
-
supervisor: import("../../link/supervisor").LinkSupervisor;
|
|
59
|
-
listener: LinkRouteListener;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
23
|
const states = new WeakMap<object, LinkRouteState>();
|
|
63
24
|
// One process runs at most one join: a join ends by restarting this process as a client.
|
|
64
25
|
let joinInProgress = false;
|
|
65
26
|
|
|
66
|
-
/**
|
|
67
|
-
* `hint` is one line bounded by `boundHint`: from ssh stderr (`sshFailureHint`), from the ssh
|
|
68
|
-
* runner's own failure (`sshRunnerErrorHint`), or the parsed remote version for
|
|
69
|
-
* `remote_ocx_outdated`. It is returned, never logged.
|
|
70
|
-
*/
|
|
71
|
-
function fail(code: string, message: string, status: number, hint?: string): Response {
|
|
72
|
-
return Response.json({ error: { code, message, ...(hint ? { hint } : {}) } }, { status, headers: { "cache-control": "no-store" } });
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
76
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function exactBody(body: unknown, keys: readonly string[]): Record<string, unknown> | null {
|
|
80
|
-
if (!isRecord(body)) return null;
|
|
81
|
-
const actual = Object.keys(body).sort();
|
|
82
|
-
const expected = [...keys].sort();
|
|
83
|
-
return actual.length === expected.length && actual.every((key, index) => key === expected[index]) ? body : null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function port(value: unknown): value is number {
|
|
87
|
-
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** A GUI session redeemed from an operator-created, one-use pairing grant. */
|
|
91
|
-
function pairedSession(ctx: ManagementContext): boolean {
|
|
92
|
-
return ctx.principal === "gui-session"
|
|
93
|
-
&& ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* A paired session, or on a standalone runtime the current loopback-issued session that reached
|
|
98
|
-
* the public listener bound to a loopback hostname. The loopback bootstrap mints that session
|
|
99
|
-
* without a credential, so this is casual-path protection like POST /api/github/star, not a
|
|
100
|
-
* secret-backed boundary like the admin token. Hubs keep the paired-only rule. Durable operations
|
|
101
|
-
* that require operator approval, such as joining this machine as a Child, use pairedSession.
|
|
102
|
-
*/
|
|
103
|
-
function dashboardSession(ctx: ManagementContext): boolean {
|
|
104
|
-
if (pairedSession(ctx)) return true;
|
|
105
|
-
return ctx.principal === "gui-session"
|
|
106
|
-
&& (ctx.config.runtimeRole ?? "standalone") === "standalone"
|
|
107
|
-
&& ctx.guiSessionIssuance === "loopback"
|
|
108
|
-
&& ctx.trustedLoopbackIngress
|
|
109
|
-
&& ctx.sessionControl?.isCurrent(ctx.req, ctx.config) === true;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function adminLoopback(ctx: ManagementContext): boolean {
|
|
113
|
-
return ctx.principal === "admin-token" && ctx.trustedLoopbackIngress;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function auth(ctx: ManagementContext, kind: "dashboard" | "paired" | "admin" | "either"): Response | null {
|
|
117
|
-
if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403);
|
|
118
|
-
const allowed = kind === "dashboard" ? dashboardSession(ctx)
|
|
119
|
-
: kind === "paired" ? pairedSession(ctx)
|
|
120
|
-
: kind === "admin" ? adminLoopback(ctx)
|
|
121
|
-
: dashboardSession(ctx) || adminLoopback(ctx);
|
|
122
|
-
return allowed ? null : fail("forbidden", "The required link authorization was not present.", 403);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
27
|
/**
|
|
126
28
|
* The client runtime a join restarts into binds exactly the configured port, with no fallback.
|
|
127
29
|
* A standalone that runs elsewhere (`ocx start --port`, or a port fallback) would restart into a
|
|
@@ -142,18 +44,6 @@ function joinDenied(ctx: ManagementContext): LinkJoinDenied | null {
|
|
|
142
44
|
return null;
|
|
143
45
|
}
|
|
144
46
|
|
|
145
|
-
function runnerFor(ctx: ManagementContext): SshRunner {
|
|
146
|
-
return ctx.deps.sshRunner ?? createSshRunner();
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function readStoreFor(ctx: ManagementContext): LinkStore {
|
|
150
|
-
return ctx.deps.readLinkStore?.() ?? readLinkStore(linkStorePath());
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function writeStoreFor(ctx: ManagementContext, store: LinkStore): void {
|
|
154
|
-
(ctx.deps.writeLinkStore ?? (value => writeLinkStore(linkStorePath(), value)))(store);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
47
|
function stateFor(ctx: ManagementContext): LinkRouteState | null {
|
|
158
48
|
const supervisor = ctx.deps.linkSupervisor?.();
|
|
159
49
|
const listener = ctx.deps.linkListener?.();
|
|
@@ -172,259 +62,11 @@ function stateFor(ctx: ManagementContext): LinkRouteState | null {
|
|
|
172
62
|
return state;
|
|
173
63
|
}
|
|
174
64
|
|
|
175
|
-
function knownHostsFile(ctx: ManagementContext): string {
|
|
176
|
-
return ctx.deps.linkKnownHostsPath?.() ?? linkKnownHostsPath();
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function putKnownHost(path: string, alias: string, line: string): string {
|
|
180
|
-
const before = (() => { try { return readFileSync(path, "utf8"); } catch { return ""; } })();
|
|
181
|
-
const lines = before.split(/\r?\n/).filter(value => value && !value.startsWith(`${alias} `) && !value.startsWith(`${alias},`));
|
|
182
|
-
lines.push(line.trim());
|
|
183
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
184
|
-
writeFileSync(path, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
185
|
-
try { chmodSync(path, 0o600); } catch { /* best effort on Windows */ }
|
|
186
|
-
return before;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function restoreKnownHost(path: string, before: string): void {
|
|
190
|
-
if (!before) {
|
|
191
|
-
try { rmSync(path, { force: true }); } catch { /* best effort compensation */ }
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
writeFileSync(path, before, { mode: 0o600 });
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function issueKey(ctx: ManagementContext, name: string): IssuedApiKey {
|
|
198
|
-
return (ctx.deps.issueApiKey ?? issueApiKeyInProcess)(ctx.config, name);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function revokeKey(ctx: ManagementContext, id: string): boolean {
|
|
202
|
-
return (ctx.deps.revokeApiKey ?? revokeApiKeyInProcess)(ctx.config, id);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Revocation that a retry can repeat. A key that is no longer in the live config is already
|
|
207
|
-
* revoked, which is exactly the state a partially completed cleanup leaves behind; without this
|
|
208
|
-
* a retried DELETE would fail forever on the key it removed the first time.
|
|
209
|
-
*/
|
|
210
|
-
function revokeKeyIdempotent(ctx: ManagementContext, id: string): boolean {
|
|
211
|
-
let revoked = false;
|
|
212
|
-
try { revoked = revokeKey(ctx, id); } catch { revoked = false; }
|
|
213
|
-
if (revoked) return true;
|
|
214
|
-
return !(ctx.config.apiKeys ?? []).some(entry => entry.id === id);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function compensationFailure(ctx: ManagementContext, state: LinkRouteState, record: LinkStore["links"][number], phase: string): Response {
|
|
218
|
-
const since = new Date((ctx.deps.now ?? Date.now)()).toISOString();
|
|
219
|
-
(state.compensationFailures ??= new Map()).set(record.id, { since, reason: "compensation_failed" });
|
|
220
|
-
try { markCompensationFailed(record.id, since, compensationPath()); } catch (error) {
|
|
221
|
-
console.warn(`[link] compensation marker persistence failed linkId=${record.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
222
|
-
}
|
|
223
|
-
console.warn(`[link] compensation_failed linkId=${record.id} phase=${phase}`);
|
|
224
|
-
return fail("compensation_failed", "The link compensation could not be completed.", 500);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async function compensateNewLink(ctx: ManagementContext, state: LinkRouteState, record: LinkStore["links"][number]): Promise<Response | null> {
|
|
228
|
-
const revoked = revokeKeyIdempotent(ctx, record.apiKeyId);
|
|
229
|
-
if (!revoked) {
|
|
230
|
-
try { await state.supervisor.stopLink(record.id); } catch { /* retain the record and failure marker */ }
|
|
231
|
-
try {
|
|
232
|
-
withConfigMutationLockSync(() => {
|
|
233
|
-
const current = readStoreFor(ctx);
|
|
234
|
-
if (!current.links.some(link => link.id === record.id)) {
|
|
235
|
-
writeStoreFor(ctx, { ...current, links: [...current.links, record] });
|
|
236
|
-
}
|
|
237
|
-
});
|
|
238
|
-
} catch {
|
|
239
|
-
// The in-memory marker still makes the residual visible when persistence is unavailable.
|
|
240
|
-
}
|
|
241
|
-
return compensationFailure(ctx, state, record, "revoke");
|
|
242
|
-
}
|
|
243
|
-
try { await state.supervisor.stopLink(record.id); } catch { return compensationFailure(ctx, state, record, "stop"); }
|
|
244
|
-
try {
|
|
245
|
-
// Read→filter→write stays one section of the shared mutation lock: a sibling
|
|
246
|
-
// route's create must not land between the read and the rename and vanish.
|
|
247
|
-
const next = withConfigMutationLockSync(() => {
|
|
248
|
-
const current = readStoreFor(ctx);
|
|
249
|
-
const filtered = { ...current, links: current.links.filter(link => link.id !== record.id) };
|
|
250
|
-
if (filtered.links.length !== current.links.length) {
|
|
251
|
-
writeStoreFor(ctx, filtered);
|
|
252
|
-
}
|
|
253
|
-
return filtered;
|
|
254
|
-
});
|
|
255
|
-
clearCompensationFailed(record.id, compensationPath());
|
|
256
|
-
state.compensationFailures?.delete(record.id);
|
|
257
|
-
if (next.links.length === 0) await state.listener.close();
|
|
258
|
-
return null;
|
|
259
|
-
} catch {
|
|
260
|
-
return compensationFailure(ctx, state, record, "store");
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
65
|
async function candidates(ctx: ManagementContext): Promise<Response> {
|
|
265
66
|
const values = ctx.deps.loadLinkCandidates?.() ?? loadHostCandidates();
|
|
266
67
|
return Response.json({ candidates: values.map(value => ({ alias: value.alias, source: value.source })) });
|
|
267
68
|
}
|
|
268
69
|
|
|
269
|
-
async function probe(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
270
|
-
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias"]);
|
|
271
|
-
if (!body || typeof body.alias !== "string") return fail("invalid_body", "alias is required.", 400);
|
|
272
|
-
try { assertSshAlias(body.alias); } catch { return fail("invalid_alias", "alias must be a valid SSH host alias.", 400); }
|
|
273
|
-
const dir = mkdtempSync(join(tmpdir(), "ocx-link-probe-"));
|
|
274
|
-
const tempKnownHosts = join(dir, "known_hosts");
|
|
275
|
-
writeFileSync(tempKnownHosts, "", { mode: 0o600 });
|
|
276
|
-
const runner = runnerFor(ctx);
|
|
277
|
-
try {
|
|
278
|
-
const result = await runner.run(buildProbeArgv({ alias: body.alias, tempKnownHostsFile: tempKnownHosts }), { timeoutMs: 30_000 });
|
|
279
|
-
if (result.code !== 0) return fail("probe_failed", "SSH host probing failed.", 502, sshFailureHint(result.stderr));
|
|
280
|
-
const fingerprintResult = await runner.run(buildFingerprintArgv(tempKnownHosts), { timeoutMs: 10_000 });
|
|
281
|
-
if (fingerprintResult.code !== 0) return fail("fingerprint_failed", "The SSH host fingerprint could not be read.", 502, sshFailureHint(fingerprintResult.stderr));
|
|
282
|
-
const parsed = parseFingerprintLine(fingerprintResult.stdout.trim());
|
|
283
|
-
const knownHostLine = readFileSync(tempKnownHosts, "utf8").trim().split(/\r?\n/).filter(Boolean).at(-1);
|
|
284
|
-
if (!knownHostLine) return fail("fingerprint_failed", "The SSH probe did not record a host key.", 502);
|
|
285
|
-
state.pendingHosts.set(body.alias, { alias: body.alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType, knownHostLine, probedAt: (ctx.deps.now ?? Date.now)() });
|
|
286
|
-
return Response.json({ alias: body.alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType });
|
|
287
|
-
} catch (error) {
|
|
288
|
-
return fail("probe_failed", "SSH host probing failed.", 502, sshRunnerErrorHint(error));
|
|
289
|
-
} finally {
|
|
290
|
-
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
async function confirmHost(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
295
|
-
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias", "fingerprint"]);
|
|
296
|
-
if (!body || typeof body.alias !== "string" || typeof body.fingerprint !== "string") return fail("invalid_body", "alias and fingerprint are required.", 400);
|
|
297
|
-
const pending = state.pendingHosts.get(body.alias);
|
|
298
|
-
const now = (ctx.deps.now ?? Date.now)();
|
|
299
|
-
if (!pending || now - pending.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host probe has expired.", 409);
|
|
300
|
-
if (pending.fingerprint !== body.fingerprint) return fail("host_fingerprint_mismatch", "The confirmed fingerprint does not match the probe.", 409);
|
|
301
|
-
const path = knownHostsFile(ctx);
|
|
302
|
-
const before = putKnownHost(path, pending.alias, pending.knownHostLine);
|
|
303
|
-
// Every refusal restores known_hosts and keeps the pending probe, so a retry within its TTL works.
|
|
304
|
-
const refuse = (response: Response): Response => { restoreKnownHost(path, before); return response; };
|
|
305
|
-
let result: SshRunResult;
|
|
306
|
-
try {
|
|
307
|
-
result = await runnerFor(ctx).run(buildExecArgv({ alias: pending.alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
|
|
308
|
-
} catch (error) {
|
|
309
|
-
return refuse(fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshRunnerErrorHint(error)));
|
|
310
|
-
}
|
|
311
|
-
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)));
|
|
312
|
-
if (result.code !== 0) return refuse(fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshFailureHint(result.stderr)));
|
|
313
|
-
const version = parseRemoteOcxVersion(result.stdout);
|
|
314
|
-
if (!version) return refuse(fail("remote_ocx_unrecognized", "The remote ocx did not report an OpenCodex version.", 502));
|
|
315
|
-
if (!meetsRemoteOcxFloor(version.parts)) {
|
|
316
|
-
return refuse(fail("remote_ocx_outdated", `The remote OpenCodex is older than ${MIN_REMOTE_OCX.join(".")}.`, 409, boundHint(`opencodex ${version.version}`)));
|
|
317
|
-
}
|
|
318
|
-
const confirmed = { ...pending, ocxVersion: version.version };
|
|
319
|
-
state.confirmedHosts ??= new Map();
|
|
320
|
-
state.confirmedHosts.set(pending.alias, confirmed);
|
|
321
|
-
state.pendingHosts.delete(pending.alias);
|
|
322
|
-
return Response.json({ alias: pending.alias, fingerprint: pending.fingerprint, ocxVersion: confirmed.ocxVersion });
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* `ocx --version` prints `opencodex <version>` first; a usage banner, an unbounded version tail or
|
|
327
|
-
* other output is refused.
|
|
328
|
-
*/
|
|
329
|
-
function parseRemoteOcxVersion(stdout: string): { version: string; parts: [number, number, number] } | null {
|
|
330
|
-
const match = REMOTE_OCX_VERSION.exec(stdout.trim().split(/\r?\n/)[0] ?? "");
|
|
331
|
-
if (!match) return null;
|
|
332
|
-
return { version: `${match[1]}.${match[2]}.${match[3]}${match[4] ?? ""}${match[5] ?? ""}`, parts: [Number(match[1]), Number(match[2]), Number(match[3])] };
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function meetsRemoteOcxFloor(parts: readonly [number, number, number]): boolean {
|
|
336
|
-
for (let index = 0; index < MIN_REMOTE_OCX.length; index += 1) {
|
|
337
|
-
if (parts[index] !== MIN_REMOTE_OCX[index]) return parts[index]! > MIN_REMOTE_OCX[index]!;
|
|
338
|
-
}
|
|
339
|
-
return true;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function parsePortOutput(stdout: string): number | null {
|
|
343
|
-
try {
|
|
344
|
-
const value: unknown = JSON.parse(stdout.trim());
|
|
345
|
-
if (!isRecord(value) || Object.keys(value).length !== 1 || !port(value.port)) return null;
|
|
346
|
-
return value.port;
|
|
347
|
-
} catch { return null; }
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
351
|
-
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias"]);
|
|
352
|
-
if (!body || typeof body.alias !== "string") return fail("invalid_body", "alias is required.", 400);
|
|
353
|
-
const confirmed = state.confirmedHosts?.get(body.alias);
|
|
354
|
-
if (!confirmed) return fail("host_not_confirmed", "Confirm the SSH host before applying the link.", 409);
|
|
355
|
-
const knownHosts = knownHostsFile(ctx);
|
|
356
|
-
const runner = runnerFor(ctx);
|
|
357
|
-
let remotePort: number;
|
|
358
|
-
let portHint: string | undefined;
|
|
359
|
-
try {
|
|
360
|
-
const result = await runner.run(buildExecArgv({ alias: body.alias, argv: remoteOcxArgv(["link", "port"]), knownHostsFile: knownHosts }), { timeoutMs: 30_000 });
|
|
361
|
-
if (result.code === REMOTE_COMMAND_NOT_FOUND) return fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr));
|
|
362
|
-
remotePort = result.code === 0 ? parsePortOutput(result.stdout) ?? 0 : 0;
|
|
363
|
-
if (result.code !== 0) portHint = sshFailureHint(result.stderr);
|
|
364
|
-
} catch (error) { remotePort = 0; portHint = sshRunnerErrorHint(error); }
|
|
365
|
-
if (!port(remotePort)) return fail("remote_port_failed", "The remote link port could not be determined.", 502, portHint);
|
|
366
|
-
let issued: IssuedApiKey;
|
|
367
|
-
try { issued = issueKey(ctx, `link:${body.alias}`); }
|
|
368
|
-
catch { return fail("key_issue_failed", "The link key could not be issued.", 503); }
|
|
369
|
-
const id = newLinkId();
|
|
370
|
-
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() };
|
|
371
|
-
let store: LinkStore;
|
|
372
|
-
try {
|
|
373
|
-
// Duplicate-check and append under one mutation-lock section: two writers
|
|
374
|
-
// racing this check could otherwise both pass it and append, losing one link.
|
|
375
|
-
const writeOutcome = withConfigMutationLockSync((): "dup" | "written" => {
|
|
376
|
-
const current = readStoreFor(ctx);
|
|
377
|
-
if (current.links.some(link => link.alias === body.alias && link.direction === "hub-initiated")) return "dup";
|
|
378
|
-
store = { ...current, links: [...current.links, record] };
|
|
379
|
-
writeStoreFor(ctx, store);
|
|
380
|
-
return "written";
|
|
381
|
-
});
|
|
382
|
-
if (writeOutcome === "dup") {
|
|
383
|
-
const compensation = await compensateNewLink(ctx, state, record);
|
|
384
|
-
if (compensation) return compensation;
|
|
385
|
-
return fail("link_exists", "A link for this alias already exists.", 409);
|
|
386
|
-
}
|
|
387
|
-
await state.listener.ensureStarted();
|
|
388
|
-
if (state.listener.status().state !== "listening") {
|
|
389
|
-
const compensation = await compensateNewLink(ctx, state, record);
|
|
390
|
-
return compensation ?? fail("listener_unavailable", "The link listener is unavailable.", 503);
|
|
391
|
-
}
|
|
392
|
-
await state.supervisor.ensureStarted();
|
|
393
|
-
await state.supervisor.reload();
|
|
394
|
-
} catch {
|
|
395
|
-
const compensation = await compensateNewLink(ctx, state, record);
|
|
396
|
-
if (compensation) return compensation;
|
|
397
|
-
return fail("link_apply_failed", "The link could not be started.", 503);
|
|
398
|
-
}
|
|
399
|
-
const admission = awaitFirstAdmission(issued.id, APPLY_ADMISSION_TIMEOUT_MS, state.listener.onAuthenticatedCatalog);
|
|
400
|
-
const input = new TextEncoder().encode(JSON.stringify({ apiKeyId: issued.id, key: issued.key }));
|
|
401
|
-
try {
|
|
402
|
-
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 });
|
|
403
|
-
if (result.code !== 0) {
|
|
404
|
-
void admission.catch(() => {});
|
|
405
|
-
const compensation = await compensateNewLink(ctx, state, record);
|
|
406
|
-
if (compensation) return compensation;
|
|
407
|
-
const hint = sshFailureHint(result.stderr);
|
|
408
|
-
return result.code === REMOTE_COMMAND_NOT_FOUND
|
|
409
|
-
? fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, hint)
|
|
410
|
-
: fail("remote_connect_failed", "The remote link connection failed.", 502, hint);
|
|
411
|
-
}
|
|
412
|
-
try {
|
|
413
|
-
await admission;
|
|
414
|
-
} catch {
|
|
415
|
-
const compensation = await compensateNewLink(ctx, state, record);
|
|
416
|
-
return compensation ?? fail("admission_timeout", "The remote link did not authenticate a catalog request in time.", 502);
|
|
417
|
-
}
|
|
418
|
-
return Response.json({ linkId: id }, { status: 202 });
|
|
419
|
-
} catch (error) {
|
|
420
|
-
void admission.catch(() => {});
|
|
421
|
-
const compensation = await compensateNewLink(ctx, state, record);
|
|
422
|
-
return compensation ?? fail("remote_connect_failed", "The remote link connection failed.", 502, sshRunnerErrorHint(error));
|
|
423
|
-
} finally {
|
|
424
|
-
input.fill(0);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
70
|
async function issue(ctx: ManagementContext): Promise<Response> {
|
|
429
71
|
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias", "tunnelPort"]);
|
|
430
72
|
if (!body || typeof body.alias !== "string" || !isLinkPort(body.tunnelPort)) return fail("invalid_body", "alias and tunnelPort are required.", 400);
|
|
@@ -525,6 +167,30 @@ async function handleJoin(ctx: ManagementContext, state: LinkRouteState): Promis
|
|
|
525
167
|
}
|
|
526
168
|
}
|
|
527
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
|
+
|
|
528
194
|
async function remove(ctx: ManagementContext, state: LinkRouteState, id: string): Promise<Response> {
|
|
529
195
|
if (!LINK_ID.test(id)) return fail("invalid_link_id", "The link id is invalid.", 400);
|
|
530
196
|
const current = readStoreFor(ctx);
|
|
@@ -533,41 +199,46 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string)
|
|
|
533
199
|
const body = await readManagementJsonBodyOr(ctx.req, {});
|
|
534
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);
|
|
535
201
|
const force = body.force === true;
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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) {
|
|
549
222
|
await restartTunnel();
|
|
550
|
-
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));
|
|
551
224
|
}
|
|
552
|
-
} catch (error) {
|
|
553
|
-
await restartTunnel();
|
|
554
|
-
return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshRunnerErrorHint(error));
|
|
555
225
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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?.(); }
|
|
571
242
|
}
|
|
572
243
|
|
|
573
244
|
export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: LinkRouteState): Promise<Response | null> {
|
|
@@ -596,6 +267,7 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
|
|
|
596
267
|
const dto: LinkStatusDto = projectLinkStatus(
|
|
597
268
|
store, state.supervisor.status(), listenerStatus, ctx.config, readCompensation(),
|
|
598
269
|
ctx.config.runtimeRole === "client" ? clientLinkTunnelStatus() : null,
|
|
270
|
+
ctx.config.runtimeRole === "client" && ctx.config.client?.link ? clientLinkStatusIdentity(ctx.config.client.link.linkId) : null,
|
|
599
271
|
);
|
|
600
272
|
for (const link of dto.links) {
|
|
601
273
|
const failure = state.compensationFailures?.get(link.id);
|
|
@@ -618,17 +290,17 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
|
|
|
618
290
|
return candidates(ctx);
|
|
619
291
|
}
|
|
620
292
|
if (url.pathname === "/api/link/probe" && req.method === "POST") {
|
|
621
|
-
const denied = auth(ctx, "
|
|
293
|
+
const denied = auth(ctx, "either");
|
|
622
294
|
if (denied) return denied;
|
|
623
295
|
return probe(ctx, state);
|
|
624
296
|
}
|
|
625
297
|
if (url.pathname === "/api/link/confirm-host" && req.method === "POST") {
|
|
626
|
-
const denied = auth(ctx, "
|
|
298
|
+
const denied = auth(ctx, "either");
|
|
627
299
|
if (denied) return denied;
|
|
628
300
|
return confirmHost(ctx, state);
|
|
629
301
|
}
|
|
630
302
|
if (url.pathname === "/api/link/apply" && req.method === "POST") {
|
|
631
|
-
const denied = auth(ctx, "
|
|
303
|
+
const denied = auth(ctx, "either");
|
|
632
304
|
if (denied) return denied;
|
|
633
305
|
return apply(ctx, state);
|
|
634
306
|
}
|
|
@@ -637,6 +309,14 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
|
|
|
637
309
|
if (denied) return denied;
|
|
638
310
|
return issue(ctx);
|
|
639
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
|
+
}
|
|
640
320
|
if (req.method === "DELETE" && path.startsWith("/api/link/")) {
|
|
641
321
|
const denied = auth(ctx, "either");
|
|
642
322
|
if (denied) return denied;
|
|
@@ -556,9 +556,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
556
556
|
if (!(error instanceof ClientPathError)) throw error;
|
|
557
557
|
return jsonResponse({ error: error.message }, 400, req, config);
|
|
558
558
|
}
|
|
559
|
-
if ((requested === "raycast" || requested === "droid") && shouldInjectApiAuthHeader(config)) {
|
|
559
|
+
if ((requested === "raycast" || requested === "droid" || requested === "qoder") && shouldInjectApiAuthHeader(config)) {
|
|
560
560
|
return jsonResponse({
|
|
561
|
-
error: `${requested === "droid" ? "Factory Droid" : "Raycast"} export requires an unauthenticated loopback destination; this listener requires an admission header the client cannot supply.`,
|
|
561
|
+
error: `${requested === "droid" ? "Factory Droid" : requested === "qoder" ? "Qoder" : "Raycast"} export requires an unauthenticated loopback destination; this listener requires an admission header the client cannot supply.`,
|
|
562
562
|
reason: "non_loopback",
|
|
563
563
|
}, 400, req, config);
|
|
564
564
|
}
|
|
@@ -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,
|
|
@@ -31,6 +31,7 @@ export const PROTOCOL_ROLLOUT_KEYS = [
|
|
|
31
31
|
"managedMessagesNative",
|
|
32
32
|
"managedMessagesNativeOAuth",
|
|
33
33
|
"directEncoders",
|
|
34
|
+
"antigravityDirect",
|
|
34
35
|
"shadowPlan",
|
|
35
36
|
] as const satisfies readonly (keyof ProtocolRolloutSettings)[];
|
|
36
37
|
const ROLLOUT_KEYS = new Set<string>(PROTOCOL_ROLLOUT_KEYS);
|
|
@@ -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 ?? [],
|