@iislee/opencodex 2.11.0 → 2.35.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/AGENTS_INSTALL.md +109 -0
- package/README.md +117 -22
- package/bin/ocx.mjs +245 -88
- package/bin/package-main.mjs +1 -1
- package/gui/dist/assets/index-Be1sDpWw.css +1 -0
- package/gui/dist/assets/index-D4OKgUV4.js +114 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +21 -10
- package/src/adapters/anthropic-output-schema.ts +137 -0
- package/src/adapters/anthropic.ts +475 -62
- package/src/adapters/base.ts +82 -7
- package/src/adapters/client-fingerprint.ts +18 -12
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
- package/src/adapters/command-code.ts +637 -0
- package/src/adapters/cursor/call-id.ts +44 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/checkpoint-store.ts +308 -0
- package/src/adapters/cursor/cursor-errors.ts +144 -6
- package/src/adapters/cursor/discovery.ts +122 -14
- package/src/adapters/cursor/effort-map.ts +106 -4
- package/src/adapters/cursor/envelope-echo.ts +290 -0
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/http1-bidi.ts +361 -0
- package/src/adapters/cursor/images.ts +704 -0
- package/src/adapters/cursor/live-models.ts +180 -59
- package/src/adapters/cursor/live-transport.ts +623 -170
- package/src/adapters/cursor/message-mapper.ts +4 -1
- package/src/adapters/cursor/native-exec-common.ts +23 -2
- package/src/adapters/cursor/native-exec-desktop.ts +23 -0
- package/src/adapters/cursor/native-exec-fs.ts +10 -7
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +5 -3
- package/src/adapters/cursor/native-exec.ts +121 -14
- package/src/adapters/cursor/protobuf-events.ts +829 -11
- package/src/adapters/cursor/protobuf-request.ts +447 -75
- package/src/adapters/cursor/request-builder.ts +271 -35
- package/src/adapters/cursor/tool-definitions.ts +252 -12
- package/src/adapters/cursor/tool-result-normalize.ts +115 -0
- package/src/adapters/cursor/transport.ts +22 -0
- package/src/adapters/cursor/types.ts +48 -1
- package/src/adapters/cursor.ts +336 -39
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +713 -41
- package/src/adapters/google-antigravity-wire.ts +48 -10
- package/src/adapters/google-errors.ts +44 -12
- package/src/adapters/google-http.ts +38 -10
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +742 -86
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/image.ts +1 -1
- package/src/adapters/kiro-constants.ts +27 -0
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +18 -2
- package/src/adapters/kiro-tools.ts +65 -17
- package/src/adapters/kiro.ts +270 -73
- package/src/adapters/mimo-free.ts +17 -0
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +1377 -344
- package/src/adapters/openai-responses-url.ts +16 -0
- package/src/adapters/openai-responses.ts +1164 -64
- package/src/adapters/registry.ts +175 -0
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/adapters/run-turn-queue.ts +36 -1
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +105 -21
- package/src/adapters/xai-tool-schema.ts +436 -0
- package/src/adapters/xai-web-search.ts +186 -0
- package/src/bridge.ts +512 -92
- package/src/chat/inbound.ts +57 -20
- package/src/chat/outbound.ts +97 -33
- package/src/claude/agents-inject.ts +40 -10
- package/src/claude/context-windows.ts +37 -14
- package/src/claude/desktop-3p.ts +243 -9
- package/src/claude/gateway-cache.ts +41 -4
- package/src/claude/inbound.ts +72 -3
- package/src/claude/model-info.ts +38 -15
- package/src/claude/outbound.ts +88 -21
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +150 -19
- package/src/cli/account-auth.ts +33 -6
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +648 -28
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +97 -19
- package/src/cli/agent.ts +53 -2
- package/src/cli/alias.ts +66 -0
- package/src/cli/capabilities-command.ts +94 -0
- package/src/cli/capabilities.ts +496 -0
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/claude-desktop.ts +60 -15
- package/src/cli/claude.ts +129 -35
- package/src/cli/codex-log-guard-doctor.ts +103 -0
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +76 -0
- package/src/cli/combo.ts +8 -0
- package/src/cli/config-command.ts +74 -10
- package/src/cli/dispatch.ts +821 -0
- package/src/cli/doctor.ts +440 -56
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/export-command.ts +46 -20
- package/src/cli/help.ts +34 -274
- package/src/cli/index.ts +417 -528
- package/src/cli/init.ts +5 -17
- package/src/cli/inspect.ts +230 -0
- package/src/cli/integrations.ts +120 -2
- package/src/cli/lab.ts +607 -0
- package/src/cli/launcher-context.ts +77 -0
- package/src/cli/minimax.ts +497 -0
- package/src/cli/models-runtime.ts +130 -2
- package/src/cli/models.ts +118 -16
- package/src/cli/observe.ts +123 -11
- package/src/cli/opencode.ts +4 -2
- package/src/cli/provider-runtime.ts +34 -3
- package/src/cli/provider.ts +25 -3
- package/src/cli/ready.ts +301 -0
- package/src/cli/registry.ts +476 -0
- package/src/cli/root.ts +86 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +57 -10
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +51 -4
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/cli/usage-report.ts +184 -0
- package/src/cli/v2.ts +137 -18
- package/src/cli/version-skew.ts +46 -0
- package/src/cli.ts +1 -1
- package/src/clients/config-export.ts +1442 -23
- package/src/clients/effective-status.ts +30 -2
- package/src/clients/probes/cline.ts +81 -0
- package/src/codex/account-label.ts +35 -1
- package/src/codex/account-lifecycle.ts +130 -13
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-pause.ts +2 -1
- package/src/codex/account-priority.ts +84 -0
- package/src/codex/account-store.ts +29 -2
- package/src/codex/account-usability.ts +25 -2
- package/src/codex/admission.ts +256 -0
- package/src/codex/affinity-debug.ts +162 -0
- package/src/codex/app-server-processes.ts +570 -107
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/auth-api.ts +893 -246
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/auth-context.ts +348 -32
- package/src/codex/autostart-health.ts +24 -1
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +86 -10
- package/src/codex/catalog/bundled.ts +331 -33
- package/src/codex/catalog/effort.ts +138 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +555 -45
- package/src/codex/catalog/native-models.ts +75 -0
- package/src/codex/catalog/parsing.ts +269 -30
- package/src/codex/catalog/provider-fetch.ts +1626 -144
- package/src/codex/catalog/sync.ts +1576 -187
- package/src/codex/catalog-admission.ts +199 -0
- package/src/codex/catalog-refresh-status.ts +105 -0
- package/src/codex/catalog-write-serialization.ts +242 -0
- package/src/codex/catalog.ts +6 -3
- package/src/codex/codex-write-lock.ts +386 -0
- package/src/codex/convergence-types.ts +614 -0
- package/src/codex/convergence.ts +676 -0
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/desktop-app-restart.ts +355 -0
- package/src/codex/features.ts +636 -39
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +436 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-manifest.ts +112 -0
- package/src/codex/history-migration-guardian.ts +30 -24
- package/src/codex/history-provider.ts +1016 -235
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +223 -0
- package/src/codex/inject-coordination.ts +373 -0
- package/src/codex/inject.ts +1114 -152
- package/src/codex/injected-marker.ts +37 -3
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +81 -0
- package/src/codex/journal.ts +66 -4
- package/src/codex/log-guard/inspect.ts +524 -0
- package/src/codex/log-guard/lock.ts +150 -0
- package/src/codex/log-guard/maintenance.ts +403 -0
- package/src/codex/log-guard/path-safety.ts +88 -0
- package/src/codex/log-guard/policy.ts +44 -0
- package/src/codex/log-guard/processes.ts +205 -0
- package/src/codex/log-guard/protection.ts +489 -0
- package/src/codex/log-guard/sqlite-errors.ts +9 -0
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/main-account.ts +29 -1
- package/src/codex/management-convergence.ts +167 -0
- package/src/codex/model-cache.ts +56 -10
- package/src/codex/model-entitlements.ts +360 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +178 -0
- package/src/codex/native-main-lock-file.ts +162 -0
- package/src/codex/native-main-owner.ts +329 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1531 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +622 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +675 -0
- package/src/codex/paths.ts +80 -1
- package/src/codex/plan-from-token.ts +140 -0
- package/src/codex/plan.ts +40 -0
- package/src/codex/plugins-doctor.ts +1 -1
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/project-config-warnings.ts +20 -6
- package/src/codex/prompt-journal.ts +352 -0
- package/src/codex/prompt-layers.ts +1313 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/prompt-text-probe.ts +238 -0
- package/src/codex/quota-rejection.ts +298 -0
- package/src/codex/quota.ts +264 -21
- package/src/codex/refresh.ts +11 -2
- package/src/codex/reset-credit-recovery.ts +1044 -0
- package/src/codex/routing.ts +514 -94
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +1065 -31
- package/src/codex/subagent-model-fallback.ts +437 -43
- package/src/codex/sync.ts +191 -2
- package/src/codex/transition-state.ts +720 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +557 -0
- package/src/codex/warmup.ts +187 -81
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +47 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/request.ts +32 -0
- package/src/combos/types.ts +91 -9
- package/src/compatibility/index.ts +26 -0
- package/src/compatibility/manifest.ts +253 -0
- package/src/compatibility/openai-responses.ts +121 -0
- package/src/config/atomic-write.ts +219 -0
- package/src/config/paths.ts +40 -0
- package/src/config/process-state.ts +309 -0
- package/src/config/provider-name.ts +24 -0
- package/src/config/provider-validation.ts +177 -0
- package/src/config/rebase-provenance.ts +68 -0
- package/src/config.ts +1713 -709
- package/src/generated/compatibility-version.json +3324 -0
- package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
- package/src/grok/inject.ts +16 -5
- package/src/grok/inspect.ts +45 -0
- package/src/grok/sync.ts +2 -2
- package/src/images/loop.ts +157 -33
- package/src/images/plan.ts +24 -13
- package/src/integrations/config-io.ts +269 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/mutation-flight.ts +71 -0
- package/src/integrations/native/ownership-preflight.ts +202 -0
- package/src/integrations/omp-yaml-source.ts +358 -0
- package/src/integrations/owned-refresh.ts +74 -0
- package/src/integrations/ownership-policy.ts +160 -0
- package/src/integrations/ownership.ts +155 -0
- package/src/integrations/registry.ts +166 -0
- package/src/integrations/serialize.ts +314 -0
- package/src/integrations/state.ts +433 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +715 -0
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/automation/budgets.ts +78 -0
- package/src/lab/automation/config-persistence.ts +256 -0
- package/src/lab/automation/constants.ts +39 -0
- package/src/lab/automation/cooldown.ts +103 -0
- package/src/lab/automation/dispatch.ts +211 -0
- package/src/lab/automation/index.ts +13 -0
- package/src/lab/automation/orchestrator.ts +499 -0
- package/src/lab/automation/persistence.ts +512 -0
- package/src/lab/automation/planner.ts +371 -0
- package/src/lab/automation/policy.ts +136 -0
- package/src/lab/automation/queue.ts +191 -0
- package/src/lab/automation/recovery.ts +24 -0
- package/src/lab/automation/route-context.ts +21 -0
- package/src/lab/automation/run-key.ts +44 -0
- package/src/lab/automation/runs-query.ts +34 -0
- package/src/lab/automation/types.ts +160 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +64 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +164 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +68 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +214 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +229 -0
- package/src/lab/events/validate.ts +781 -0
- package/src/lab/fabric/constants.ts +40 -0
- package/src/lab/fabric/executor.ts +492 -0
- package/src/lab/fabric/index.ts +80 -0
- package/src/lab/fabric/manifest.ts +222 -0
- package/src/lab/fabric/observe.ts +489 -0
- package/src/lab/fabric/patch.ts +79 -0
- package/src/lab/fabric/producer-child.ts +139 -0
- package/src/lab/fabric/producer-isolate.ts +276 -0
- package/src/lab/fabric/producer-protocol.ts +61 -0
- package/src/lab/fabric/scratch.ts +439 -0
- package/src/lab/fabric/subject.ts +106 -0
- package/src/lab/fabric/types.ts +134 -0
- package/src/lab/fabric/verifier.ts +98 -0
- package/src/lab/index.ts +54 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +310 -0
- package/src/lab/ledger/store.ts +532 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +336 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +118 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +301 -0
- package/src/lab/observe/from-live.ts +117 -0
- package/src/lab/paths.ts +153 -0
- package/src/lab/projection/rebuild.ts +495 -0
- package/src/lab/projection/schema.ts +135 -0
- package/src/lab/projection/verdicts.ts +474 -0
- package/src/lab/projection/verification.ts +412 -0
- package/src/lab/public/bundle.ts +217 -0
- package/src/lab/public/community-authority.ts +175 -0
- package/src/lab/public/community-files.ts +29 -0
- package/src/lab/public/community.ts +479 -0
- package/src/lab/public/file-safety.ts +155 -0
- package/src/lab/public/ids.ts +26 -0
- package/src/lab/public/index.ts +16 -0
- package/src/lab/public/mutation-lock.ts +424 -0
- package/src/lab/public/operator.ts +353 -0
- package/src/lab/public/origin-purge.ts +79 -0
- package/src/lab/public/origin.ts +203 -0
- package/src/lab/public/privacy.ts +143 -0
- package/src/lab/public/private-file.ts +261 -0
- package/src/lab/public/project.ts +124 -0
- package/src/lab/public/purge-test-fault.ts +21 -0
- package/src/lab/public/purge.ts +223 -0
- package/src/lab/public/registry.ts +44 -0
- package/src/lab/public/revocation.ts +252 -0
- package/src/lab/public/signature.ts +243 -0
- package/src/lab/public/storage.ts +105 -0
- package/src/lab/public/strict-json.ts +206 -0
- package/src/lab/public/time.ts +26 -0
- package/src/lab/public/types.ts +172 -0
- package/src/lab/public/validate.ts +391 -0
- package/src/lab/query/catalog.ts +101 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/freshness.ts +53 -0
- package/src/lab/query/index.ts +45 -0
- package/src/lab/query/latest-observation.ts +59 -0
- package/src/lab/query/passive-production.ts +159 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +112 -0
- package/src/lab/subject/protocol-subject.ts +80 -0
- package/src/lab/subject/route-subject.ts +74 -0
- package/src/lib/admin-secrets.ts +24 -0
- package/src/lib/app-owned-memory-stores.ts +22 -0
- package/src/lib/bounded-body.ts +167 -11
- package/src/lib/bun-runtime.ts +125 -12
- package/src/lib/bun-stream-caps.ts +13 -9
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/config-ownership.ts +26 -2
- package/src/lib/destination-policy.ts +65 -1
- package/src/lib/errors.ts +80 -5
- package/src/lib/fabric-task-execution-authority.ts +7 -0
- package/src/lib/fabric-task-host.ts +29 -0
- package/src/lib/lab-activation.ts +223 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +56 -0
- package/src/lib/lab-live-route-production.ts +130 -0
- package/src/lib/lab-passive-linker-registration.ts +26 -0
- package/src/lib/local-management-attestation.ts +51 -0
- package/src/lib/local-management-capability.ts +100 -0
- package/src/lib/local-provider-reload-contract.ts +100 -0
- package/src/lib/optional-shutdown-hooks.ts +57 -0
- package/src/lib/package-tree-integrity.ts +101 -0
- package/src/lib/pinned-http.ts +145 -26
- package/src/lib/process-control.ts +6 -2
- package/src/lib/provider-outbound.ts +49 -9
- package/src/lib/redact.ts +419 -3
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/server-resource-ownership.ts +71 -0
- package/src/lib/service-secrets.ts +15 -0
- package/src/lib/shadow-call.ts +35 -4
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/state-store-registrations.ts +10 -2
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/token-estimate.ts +19 -2
- package/src/lib/tool-argument-integers.ts +253 -0
- package/src/lib/translator-budget.ts +44 -0
- package/src/lib/upstream-http-version.ts +57 -0
- package/src/lib/upstream-reachability.ts +95 -0
- package/src/lib/upstream-retry.ts +156 -3
- package/src/lib/windows-atomic-replace.ts +155 -0
- package/src/lib/windows-elevation.ts +70 -2
- package/src/lib/windows-secret-acl.ts +409 -69
- package/src/lib/windows-service-wrappers.ts +72 -0
- package/src/lib/windows-text.ts +106 -0
- package/src/lib/windows-user-principal.ts +341 -0
- package/src/lib/winsw.ts +33 -5
- package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
- package/src/oauth/account-import/index.ts +15 -0
- package/src/oauth/account-import/parser.ts +83 -0
- package/src/oauth/account-import/registry.ts +18 -0
- package/src/oauth/account-import/service.ts +75 -0
- package/src/oauth/account-import/types.ts +91 -0
- package/src/oauth/anthropic.ts +12 -1
- package/src/oauth/callback-server.ts +30 -4
- package/src/oauth/chatgpt.ts +12 -1
- package/src/oauth/cline.ts +203 -0
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +46 -5
- package/src/oauth/generic-account-failover.ts +231 -0
- package/src/oauth/google-antigravity.ts +35 -3
- package/src/oauth/health.ts +20 -12
- package/src/oauth/index.ts +462 -71
- package/src/oauth/key-providers.ts +25 -0
- package/src/oauth/kimi.ts +25 -3
- package/src/oauth/kiro.ts +95 -6
- package/src/oauth/local-token-detect.ts +13 -2
- package/src/oauth/log.ts +3 -1
- package/src/oauth/login-cli.ts +88 -28
- package/src/oauth/nous.ts +798 -0
- package/src/oauth/open-browser-choice.ts +26 -0
- package/src/oauth/store.ts +133 -26
- package/src/oauth/token-guardian.ts +9 -3
- package/src/oauth/types.ts +15 -0
- package/src/pi/models.ts +2 -2
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/antigravity-models.ts +521 -31
- package/src/providers/auto-compact-budget.ts +65 -0
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/codex-capacity.ts +292 -0
- package/src/providers/command-code-efforts.ts +176 -0
- package/src/providers/context-cap.ts +26 -8
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/default-aliases.ts +65 -0
- package/src/providers/derive.ts +281 -6
- package/src/providers/fastwire.ts +507 -0
- package/src/providers/free-directory.ts +10 -7
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/label.ts +35 -2
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +115 -22
- package/src/providers/model-presets.ts +119 -0
- package/src/providers/model-rename-migration.ts +255 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/new-model-policy.ts +146 -0
- package/src/providers/openai-sidecar.ts +72 -4
- package/src/providers/openai-tier-startup.ts +31 -2
- package/src/providers/openai-tiers.ts +119 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +30 -0
- package/src/providers/quota.ts +1379 -40
- package/src/providers/registry.ts +1545 -112
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +277 -0
- package/src/providers/slug-codec.ts +94 -6
- package/src/providers/static-model-discovery.ts +86 -0
- package/src/providers/xai-responses-opt-in.ts +15 -0
- package/src/providers/xai-transport.ts +32 -4
- package/src/reasoning-effort.ts +68 -3
- package/src/responses/apply-patch-envelope.ts +63 -0
- package/src/responses/code-mode-helper-compat.ts +50 -0
- package/src/responses/compaction.ts +26 -1
- package/src/responses/custom-tool-compat.ts +384 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/namespace-tool-compat.ts +435 -0
- package/src/responses/parser.ts +225 -38
- package/src/responses/provider-continuation.ts +98 -0
- package/src/responses/provider-opaque-metadata.ts +73 -0
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +504 -0
- package/src/responses/schema.ts +12 -2
- package/src/responses/spill-store.ts +75 -10
- package/src/responses/state.ts +659 -31
- package/src/responses/thought-signature-replay.ts +364 -0
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/responses/turn-termination.ts +107 -0
- package/src/router.ts +405 -31
- package/src/routing/analytics.ts +379 -0
- package/src/routing/capability.ts +244 -0
- package/src/routing/compatibility/assemble.ts +73 -0
- package/src/routing/compatibility/behavior.ts +278 -0
- package/src/routing/compatibility/catalog.ts +99 -0
- package/src/routing/compatibility/endpoint.ts +52 -0
- package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
- package/src/routing/compatibility/policy.ts +181 -0
- package/src/routing/compatibility/provider-slot.ts +56 -0
- package/src/routing/compatibility/reader.ts +110 -0
- package/src/routing/compatibility/subject.ts +191 -0
- package/src/routing/compatibility/types.ts +64 -0
- package/src/routing/compatibility/version.ts +104 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +495 -0
- package/src/routing/health.ts +412 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +605 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +547 -0
- package/src/routing/quota.ts +155 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +776 -0
- package/src/server/adapter-resolve.ts +2 -29
- package/src/server/auth-cors.ts +289 -48
- package/src/server/background-lifecycle.ts +182 -0
- package/src/server/chat-completions.ts +154 -72
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +445 -0
- package/src/server/claude-messages.ts +160 -44
- package/src/server/direct-local-http.ts +347 -0
- package/src/server/effort-policy.ts +18 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +94 -12
- package/src/server/index.ts +1021 -190
- package/src/server/lifecycle.ts +336 -14
- package/src/server/live.ts +136 -17
- package/src/server/local-management-read-client.ts +90 -0
- package/src/server/local-provider-reload-client.ts +137 -0
- package/src/server/management/agent-settings-routes.ts +433 -116
- package/src/server/management/api-key-usage.ts +31 -5
- package/src/server/management/body.ts +6 -0
- package/src/server/management/codex-prompt-routes.ts +570 -0
- package/src/server/management/combo-routes.ts +64 -25
- package/src/server/management/config-routes.ts +511 -51
- package/src/server/management/context.ts +89 -2
- package/src/server/management/integration-routes.ts +498 -0
- package/src/server/management/lab-automation-routes.ts +206 -0
- package/src/server/management/lab-routes.ts +563 -0
- package/src/server/management/logs-usage-routes.ts +127 -52
- package/src/server/management/model-routes.ts +461 -133
- package/src/server/management/model-rows.ts +167 -0
- package/src/server/management/native-integration-routes.ts +770 -0
- package/src/server/management/oauth-account-routes.ts +90 -8
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +836 -159
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/route-registry.ts +332 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +380 -0
- package/src/server/management/shared.ts +27 -11
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/storage-log-guard-routes.ts +186 -0
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +289 -33
- package/src/server/management/system-routes.ts +77 -0
- package/src/server/management/usage-summary-cache.ts +9 -1
- package/src/server/management/vision-sidecar-options.ts +167 -0
- package/src/server/management/web-search-sidecar-options.ts +120 -0
- package/src/server/management-api.ts +126 -23
- package/src/server/management-auth.ts +220 -5
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/port-reclaim.ts +1 -1
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +161 -10
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +221 -54
- package/src/server/relay.ts +479 -87
- package/src/server/request-decompress.ts +113 -6
- package/src/server/request-log-conversation.ts +51 -0
- package/src/server/request-log.ts +298 -31
- package/src/server/responses/agent-task-recovery-cache.ts +143 -0
- package/src/server/responses/agent-task-recovery.ts +465 -0
- package/src/server/responses/codex-auth-error.ts +55 -0
- package/src/server/responses/collaboration.ts +239 -36
- package/src/server/responses/combo-stream-preflight.ts +178 -0
- package/src/server/responses/compact.ts +444 -72
- package/src/server/responses/core.ts +3579 -436
- package/src/server/responses/empty-completion-guard.ts +333 -0
- package/src/server/responses/encrypted-payload.ts +62 -39
- package/src/server/responses/fetch-helpers.ts +89 -99
- package/src/server/responses/input-admission.ts +187 -0
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/passthrough-error.ts +33 -9
- package/src/server/responses/policy-fallback.ts +178 -0
- package/src/server/responses/responses-field-backfill.ts +343 -0
- package/src/server/responses/terminal-guard.ts +26 -5
- package/src/server/responses/upstream-error.ts +5 -0
- package/src/server/responses/ws-upstream.ts +381 -0
- package/src/server/responses-custom-tool-repair.ts +363 -0
- package/src/server/responses-item-id-repair.ts +54 -6
- package/src/server/responses-json-events.ts +90 -0
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/responses-terminal-repair.ts +363 -0
- package/src/server/responses-tool-search-repair.ts +267 -0
- package/src/server/responses-undeclared-tool-guard.ts +376 -0
- package/src/server/responses.ts +18 -2
- package/src/server/search.ts +78 -13
- package/src/server/sse-frame-buffer.ts +319 -0
- package/src/server/sse-payload-rewrite.ts +110 -22
- package/src/server/startup-action-control.ts +8 -1
- package/src/server/startup-health-cache.ts +19 -1
- package/src/server/system-env.ts +80 -9
- package/src/server/ws-bridge.ts +53 -40
- package/src/service-manager-probe.ts +892 -0
- package/src/service.ts +1153 -94
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/storage/cleanup.ts +2 -2
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +88 -23
- package/src/storage/scanner.ts +1 -1
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +83 -9
- package/src/tray/windows.ts +43 -16
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +911 -0
- package/src/types/provider.ts +614 -0
- package/src/types/request.ts +397 -0
- package/src/types/tools.ts +236 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +107 -1237
- package/src/update/index.ts +37 -23
- package/src/update/job.ts +445 -68
- package/src/update/notify.ts +12 -6
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/update/transactional-install.d.mts +22 -0
- package/src/update/transactional-install.mjs +266 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +287 -16
- package/src/usage/log.ts +666 -50
- package/src/usage/summary.ts +388 -17
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/anthropic-describe.ts +10 -6
- package/src/vision/backends.ts +97 -0
- package/src/vision/describe.ts +27 -16
- package/src/vision/eligibility.ts +250 -0
- package/src/vision/index.ts +238 -24
- package/src/vision/reasoning.ts +55 -0
- package/src/vision/routed-describe.ts +175 -0
- package/src/vision/timeout-bounds.ts +9 -0
- package/src/web-search/anthropic-executor.ts +13 -7
- package/src/web-search/backends.ts +108 -0
- package/src/web-search/exa-executor.ts +119 -0
- package/src/web-search/executor.ts +21 -6
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +150 -15
- package/src/web-search/loop.ts +284 -54
- package/src/web-search/parse.ts +125 -30
- package/src/web-search/sources.ts +60 -0
- package/src/web-search/xai-executor.ts +219 -0
- package/gui/dist/assets/index-DTpMHS4F.js +0 -67
- package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
- package/src/cli/internal-dispatch.ts +0 -20
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nous Portal OAuth flow (device authorization grant, RFC 8628).
|
|
3
|
+
*
|
|
4
|
+
* Nous Research's unified subscription gateway — the same backend Hermes Agent
|
|
5
|
+
* uses. The Portal is a single account surface for both the paid subscription
|
|
6
|
+
* (billed against the account) and a set of free models (the `:free` slugs such
|
|
7
|
+
* as `tencent/hy3:free`, `inclusionai/ling-3.0-flash:free`).
|
|
8
|
+
*
|
|
9
|
+
* Verified against Hermes `hermes_cli/auth.py` (2026-08):
|
|
10
|
+
* - device endpoint: POST {portal}/api/oauth/device/code
|
|
11
|
+
* - token endpoint: POST {portal}/api/oauth/token
|
|
12
|
+
* - the access token returned by the token endpoint IS the per-request
|
|
13
|
+
* inference JWT (scope `inference:invoke`) and is used directly as
|
|
14
|
+
* `Authorization: *** against the OpenAI-compatible inference API at
|
|
15
|
+
* https://inference-api.nousresearch.com/v1.
|
|
16
|
+
* - refresh sends the refresh token in the `x-nous-refresh-token` HEADER (not
|
|
17
|
+
* the body): `POST /api/oauth/token` with `grant_type=refresh_token` +
|
|
18
|
+
* `client_id`, header `x-nous-refresh-token: <rt>`.
|
|
19
|
+
* - Nous refresh tokens are SINGLE-USE: every successful refresh rotates the
|
|
20
|
+
* token, and reuse (e.g. two processes refreshing concurrently) is treated as
|
|
21
|
+
* token theft and revokes the whole session (`refresh_token_reused`).
|
|
22
|
+
* OpenCodex's refresh path persists the rotated token immediately
|
|
23
|
+
* (`mergeAccountCredential`), which is exactly the discipline the Portal
|
|
24
|
+
* expects; proactive background refresh must stay off for this provider.
|
|
25
|
+
*
|
|
26
|
+
* Single-use refresh is made failure-atomic (review blocker #2): a durable
|
|
27
|
+
* refresh-intent file is written BEFORE the refresh request and only removed
|
|
28
|
+
* after the rotated token is obtained. If we ever receive a server response
|
|
29
|
+
* but fail to persist the rotated token, the intent is marked "uncertain" and
|
|
30
|
+
* the next refresh refuses to replay the (possibly consumed) token, forcing a
|
|
31
|
+
* clean re-authentication instead of a silent session-revoking replay. After
|
|
32
|
+
* dispatch, ANY non-2xx outcome (429, unknown/custom 4xx, 5xx, gateway errors)
|
|
33
|
+
* retains the durable intent: no HTTP status class proves the single-use token
|
|
34
|
+
* was not consumed, so the submitted token is never automatically replayed.
|
|
35
|
+
*/
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
37
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { join } from "node:path";
|
|
39
|
+
import type { OAuthController, OAuthCredentials } from "./types";
|
|
40
|
+
import { getAuthStorePath } from "./store";
|
|
41
|
+
import { atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config";
|
|
42
|
+
import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body";
|
|
43
|
+
|
|
44
|
+
export const NOUS_PORTAL_BASE_URL = "https://portal.nousresearch.com";
|
|
45
|
+
export const NOUS_INFERENCE_BASE_URL = "https://inference-api.nousresearch.com/v1";
|
|
46
|
+
export const NOUS_OAUTH_CLIENT_ID = "hermes-cli";
|
|
47
|
+
export const NOUS_OAUTH_SCOPE = "inference:invoke";
|
|
48
|
+
|
|
49
|
+
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
50
|
+
const MAX_POLL_INTERVAL_MS = 30_000;
|
|
51
|
+
const DEFAULT_DEVICE_FLOW_TTL_MS = 15 * 60 * 1000;
|
|
52
|
+
// Fallback lifetime for an inference access token when neither the JWT `exp`
|
|
53
|
+
// claim nor `expires_in` is present. Kept distinct from the device-flow window:
|
|
54
|
+
// the two are unrelated durations.
|
|
55
|
+
const DEFAULT_ACCESS_TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
|
|
56
|
+
// Upper bound on a plausible inference-JWT lifetime; guards against a bad `exp`
|
|
57
|
+
// unit (ms instead of s) or a badly skewed clock.
|
|
58
|
+
const MAX_PLAUSIBLE_TOKEN_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
|
|
59
|
+
const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
|
|
60
|
+
const OAUTH_EXPIRY_SKEW_MS = 2 * 60 * 1000;
|
|
61
|
+
|
|
62
|
+
interface NousDeviceAuthorizationResponse {
|
|
63
|
+
device_code?: unknown;
|
|
64
|
+
user_code?: unknown;
|
|
65
|
+
verification_uri?: unknown;
|
|
66
|
+
verification_uri_complete?: unknown;
|
|
67
|
+
expires_in?: unknown;
|
|
68
|
+
interval?: unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface NousTokenResponse {
|
|
72
|
+
access_token?: unknown;
|
|
73
|
+
refresh_token?: unknown;
|
|
74
|
+
expires_in?: unknown;
|
|
75
|
+
token_type?: unknown;
|
|
76
|
+
scope?: unknown;
|
|
77
|
+
inference_base_url?: unknown;
|
|
78
|
+
error?: unknown;
|
|
79
|
+
error_description?: unknown;
|
|
80
|
+
interval?: unknown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface NousJwtPayload {
|
|
84
|
+
sub?: unknown;
|
|
85
|
+
email?: unknown;
|
|
86
|
+
exp?: unknown;
|
|
87
|
+
scope?: unknown;
|
|
88
|
+
[key: string]: unknown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readOAuthBytes(response: Response, signal: AbortSignal): Promise<Uint8Array> {
|
|
92
|
+
const { bytes, oversized } = await readBoundedResponseBytes(response, {
|
|
93
|
+
maxBytes: BOUNDED_BODY_MAX_BYTES,
|
|
94
|
+
signal,
|
|
95
|
+
});
|
|
96
|
+
if (oversized) {
|
|
97
|
+
throw new NousTokenError(
|
|
98
|
+
response.status,
|
|
99
|
+
"response_too_large",
|
|
100
|
+
`Nous Portal OAuth response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return bytes;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseOAuthJson(bytes: Uint8Array): unknown {
|
|
107
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
108
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
109
|
+
? parsed as Record<string, unknown>
|
|
110
|
+
: {};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readOAuthJson(response: Response, signal: AbortSignal): Promise<unknown> {
|
|
114
|
+
return parseOAuthJson(await readOAuthBytes(response, signal));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readOAuthJsonOrEmpty(response: Response, signal: AbortSignal): Promise<unknown> {
|
|
118
|
+
const bytes = await readOAuthBytes(response, signal);
|
|
119
|
+
try {
|
|
120
|
+
return parseOAuthJson(bytes);
|
|
121
|
+
} catch {
|
|
122
|
+
// Preserve the pre-PR behavior for empty/HTML/malformed JSON only. Body
|
|
123
|
+
// read failures, timeouts, caller cancellation, and size-limit errors have
|
|
124
|
+
// already escaped readOAuthBytes and must retain their real identity.
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Durable refresh-intent (review blocker #2) ──────────────────────────────
|
|
130
|
+
// A refresh-intent file records that we submitted `refreshToken` to the Portal
|
|
131
|
+
// and whether we are certain the rotated token was persisted. It lives next to
|
|
132
|
+
// the auth store (same config dir) and is keyed by a hash of the refresh
|
|
133
|
+
// token, so it never contains the token in cleartext.
|
|
134
|
+
//
|
|
135
|
+
// The mechanism reuses the repository's hardened config IO (atomicWriteFile +
|
|
136
|
+
// hardenConfigDir from ../config and ./store) and is FAIL-CLOSED: if we cannot
|
|
137
|
+
// durably create or read the intent, we refuse the refresh rather than silently
|
|
138
|
+
// disable the guard. An unreadable/corrupt intent is treated as "uncertain"
|
|
139
|
+
// (replay refused), never as "absent".
|
|
140
|
+
//
|
|
141
|
+
// States:
|
|
142
|
+
// - "submitted": we sent this token and a server response was received (so it
|
|
143
|
+
// may have been consumed). We leave it set after a successful rotation until
|
|
144
|
+
// the store confirms persistence of the rotated token; if we crash before
|
|
145
|
+
// that, a later replay of the same token is refused.
|
|
146
|
+
// - "uncertain": the dispatch may have reached the server (we saw an error
|
|
147
|
+
// after sending, or failed to parse/persist the rotated token). Replay is
|
|
148
|
+
// refused.
|
|
149
|
+
|
|
150
|
+
type RefreshIntentStatus = "submitted" | "uncertain";
|
|
151
|
+
|
|
152
|
+
interface RefreshIntent {
|
|
153
|
+
status: RefreshIntentStatus;
|
|
154
|
+
updatedAt: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export class RefreshIntentIOError extends Error {
|
|
158
|
+
constructor(message: string, cause?: unknown) {
|
|
159
|
+
super(message, cause ? { cause } : undefined);
|
|
160
|
+
this.name = "RefreshIntentIOError";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function refreshIntentDir(): string {
|
|
165
|
+
return join(getAuthStorePath(), "..", ".nous-refresh-intent");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function refreshIntentPath(refreshToken: string): string {
|
|
169
|
+
const hash = createHash("sha256").update(refreshToken).digest("hex");
|
|
170
|
+
return join(refreshIntentDir(), `${hash}.json`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Validate a persisted refresh-intent payload BEFORE trusting it. A syntactically
|
|
175
|
+
* valid JSON blob is not enough: `{}` or a wrong-shaped object must not silently
|
|
176
|
+
* produce `status === undefined` (which would bypass the replay guard). We only
|
|
177
|
+
* accept an object whose `status` is exactly one supported state and whose
|
|
178
|
+
* `updatedAt` is a finite number. Anything else is treated as `uncertain` —
|
|
179
|
+
* never as absent — so a corrupt intent can never make a possibly-consumed
|
|
180
|
+
* token replayable.
|
|
181
|
+
*/
|
|
182
|
+
function parseRefreshIntent(raw: string): RefreshIntent {
|
|
183
|
+
let value: unknown;
|
|
184
|
+
try {
|
|
185
|
+
value = JSON.parse(raw);
|
|
186
|
+
} catch {
|
|
187
|
+
return { status: "uncertain", updatedAt: Date.now() };
|
|
188
|
+
}
|
|
189
|
+
if (typeof value !== "object" || value === null) {
|
|
190
|
+
return { status: "uncertain", updatedAt: Date.now() };
|
|
191
|
+
}
|
|
192
|
+
const candidate = value as Record<string, unknown>;
|
|
193
|
+
const status = candidate.status;
|
|
194
|
+
if (status !== "submitted" && status !== "uncertain") {
|
|
195
|
+
return { status: "uncertain", updatedAt: Date.now() };
|
|
196
|
+
}
|
|
197
|
+
if (typeof candidate.updatedAt !== "number" || !Number.isFinite(candidate.updatedAt)) {
|
|
198
|
+
return { status: "uncertain", updatedAt: Date.now() };
|
|
199
|
+
}
|
|
200
|
+
return { status, updatedAt: candidate.updatedAt };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readRefreshIntent(refreshToken: string): RefreshIntent | undefined {
|
|
204
|
+
const path = refreshIntentPath(refreshToken);
|
|
205
|
+
try {
|
|
206
|
+
// Mirror the repository's hardened read: chmod/ACL-harden the secret path
|
|
207
|
+
// before reading, and treat any read/parse error as uncertain (fail-closed)
|
|
208
|
+
// rather than silently absent.
|
|
209
|
+
hardenExistingSecret(path);
|
|
210
|
+
return parseRefreshIntent(readFileSync(path, "utf8"));
|
|
211
|
+
} catch (error) {
|
|
212
|
+
// ENOENT means we never recorded an intent for this token -> safe to proceed.
|
|
213
|
+
if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
// Any other failure (corrupt JSON, permission, ACL) => uncertain: never
|
|
217
|
+
// assume the token is replayable.
|
|
218
|
+
return { status: "uncertain", updatedAt: Date.now() };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function writeRefreshIntent(refreshToken: string, status: RefreshIntentStatus): void {
|
|
223
|
+
const dir = refreshIntentDir();
|
|
224
|
+
// Hardened, owner-only directory + atomic (temp+rename) write. Throws on
|
|
225
|
+
// failure so the caller can fail closed instead of refreshing blind.
|
|
226
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
227
|
+
// mkdirSync only applies the mode at creation; re-apply owner-only on an
|
|
228
|
+
// existing directory so a permissive pre-existing dir is corrected. Fail
|
|
229
|
+
// closed: if the directory cannot be hardened to owner-only, do not write
|
|
230
|
+
// refresh-intent data into a directory a local attacker may observe.
|
|
231
|
+
chmodSync(dir, 0o700);
|
|
232
|
+
hardenConfigDir();
|
|
233
|
+
const path = refreshIntentPath(refreshToken);
|
|
234
|
+
try {
|
|
235
|
+
atomicWriteFile(path, JSON.stringify({ status, updatedAt: Date.now() } satisfies RefreshIntent));
|
|
236
|
+
} catch (error) {
|
|
237
|
+
throw new RefreshIntentIOError(`Failed to durably record Nous refresh intent (${status}); refusing refresh to avoid replaying a possibly-consumed token`, error);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function clearRefreshIntent(refreshToken: string): void {
|
|
242
|
+
try {
|
|
243
|
+
rmSync(refreshIntentPath(refreshToken), { force: true });
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
246
|
+
// A non-ENOENT failure to clear is concerning but non-fatal for the caller;
|
|
247
|
+
// the next replay guard still keys off the (now possibly stale) file.
|
|
248
|
+
throw new RefreshIntentIOError("Failed to clear Nous refresh intent", error);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* True when replaying this refresh token is unsafe: we previously submitted it
|
|
254
|
+
* and either got a response (so it may have been consumed) or failed to confirm
|
|
255
|
+
* the rotation persisted. In that uncertain state we must never blindly replay
|
|
256
|
+
* it — a replay could trigger `refresh_token_reused` and revoke the session.
|
|
257
|
+
* The caller should force a clean re-authentication instead.
|
|
258
|
+
*/
|
|
259
|
+
export function nousRefreshIntentBlocksReplay(refreshToken: string): boolean {
|
|
260
|
+
return readRefreshIntent(refreshToken)?.status !== undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Base URL hardening (review blocker #1, also flagged by multiple reviewers) ─
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Normalize and hard-validate the Nous Portal OAuth base URL.
|
|
267
|
+
*
|
|
268
|
+
* Security: the portal accepts the bearer-equivalent single-use refresh token
|
|
269
|
+
* in the `x-nous-refresh-token` header and returns the per-request inference
|
|
270
|
+
* JWT as the access token. Sending either over cleartext (or to a
|
|
271
|
+
* credential/query/fragment-laden URL) leaks credentials to a network
|
|
272
|
+
* attacker. Validate the *complete* URL up front and throw before any
|
|
273
|
+
* `fetch` is dispatched — both the device-grant and the refresh path call
|
|
274
|
+
* this from inside their `fetch` arguments, so a thrown error guarantees the
|
|
275
|
+
* network call never runs.
|
|
276
|
+
*
|
|
277
|
+
* Mirrors the https-only default in Hermes `hermes_cli/auth.py`
|
|
278
|
+
* (`DEFAULT_NOUS_PORTAL_URL`). Note: unlike Hermes, this function does not pin
|
|
279
|
+
* the host to an allowlist; any HTTPS origin passes.
|
|
280
|
+
*/
|
|
281
|
+
function resolvePortalBaseUrl(): string {
|
|
282
|
+
const raw = (process.env.NOUS_PORTAL_BASE_URL || NOUS_PORTAL_BASE_URL).trim();
|
|
283
|
+
let url: URL;
|
|
284
|
+
try {
|
|
285
|
+
url = new URL(raw);
|
|
286
|
+
} catch {
|
|
287
|
+
// Do not echo the raw value: it may contain embedded credentials. Identify
|
|
288
|
+
// the configuration problem without reflecting secret-bearing input.
|
|
289
|
+
throw new NousTokenError(undefined, undefined, "Nous Portal base URL is not a valid URL");
|
|
290
|
+
}
|
|
291
|
+
if (url.protocol !== "https:") {
|
|
292
|
+
throw new NousTokenError(undefined, undefined, `Nous Portal base URL must use HTTPS (got ${url.protocol})`);
|
|
293
|
+
}
|
|
294
|
+
if (url.username || url.password) {
|
|
295
|
+
throw new NousTokenError(undefined, undefined, "Nous Portal base URL must not contain embedded credentials");
|
|
296
|
+
}
|
|
297
|
+
if (url.search) {
|
|
298
|
+
throw new NousTokenError(undefined, undefined, "Nous Portal base URL must not contain a query string");
|
|
299
|
+
}
|
|
300
|
+
if (url.hash) {
|
|
301
|
+
throw new NousTokenError(undefined, undefined, "Nous Portal base URL must not contain a fragment");
|
|
302
|
+
}
|
|
303
|
+
// Origin only — no path/query/fragment — so callers cannot smuggle a
|
|
304
|
+
// non-canonical endpoint through the override.
|
|
305
|
+
return url.origin;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function decodeJwtPayload(token: string): NousJwtPayload | undefined {
|
|
309
|
+
const parts = token.split(".");
|
|
310
|
+
const payload = parts[1];
|
|
311
|
+
if (parts.length !== 3 || !payload) return undefined;
|
|
312
|
+
try {
|
|
313
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as NousJwtPayload;
|
|
314
|
+
} catch {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
320
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Best-effort multiauth identity from the Nous inference JWT claims. The Portal
|
|
325
|
+
* mints these tokens per login; `sub` is the stable subject and `email` is
|
|
326
|
+
* lowercased when present. Opaque (non-JWT) tokens carry no identity and are
|
|
327
|
+
* rejected downstream by the `inference:invoke` scope gate (parseTokenPayload),
|
|
328
|
+
* so a usable access token is always a decodable JWT.
|
|
329
|
+
*/
|
|
330
|
+
export function identityFromNousTokens(accessToken: string): { accountId?: string; email?: string } {
|
|
331
|
+
const payload = decodeJwtPayload(accessToken);
|
|
332
|
+
if (!payload) return {};
|
|
333
|
+
const accountId = nonEmptyString(payload.sub);
|
|
334
|
+
const email = nonEmptyString(payload.email)?.toLowerCase();
|
|
335
|
+
return {
|
|
336
|
+
...(accountId ? { accountId } : {}),
|
|
337
|
+
...(email ? { email } : {}),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** JWT `exp` (epoch seconds) → expiry ms, when present and sane. */
|
|
342
|
+
function jwtExpiryMs(payload: NousJwtPayload | undefined): number | undefined {
|
|
343
|
+
const exp = payload?.exp;
|
|
344
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) return undefined;
|
|
345
|
+
const expMs = exp * 1000;
|
|
346
|
+
// Ignore an implausible claim (past, or absurdly far in the future) and let
|
|
347
|
+
// the caller fall back to `expires_in`. A too-large `exp` (e.g. ms instead of
|
|
348
|
+
// seconds, or clock skew) would otherwise pin the credential as never
|
|
349
|
+
// expiring, and a too-small one would force an immediate refresh that burns a
|
|
350
|
+
// single-use token.
|
|
351
|
+
if (expMs <= Date.now() || expMs > Date.now() + MAX_PLAUSIBLE_TOKEN_LIFETIME_MS) return undefined;
|
|
352
|
+
return expMs;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Does the inference JWT grant the required `inference:invoke` scope? */
|
|
356
|
+
function jwtGrantsInference(payload: NousJwtPayload | undefined): boolean {
|
|
357
|
+
const scope = nonEmptyString(payload?.scope);
|
|
358
|
+
if (!scope) return false;
|
|
359
|
+
// Scope is a space-separated list per RFC 6749.
|
|
360
|
+
return scope.split(/\s+/).includes(NOUS_OAUTH_SCOPE);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export class NousTokenError extends Error {
|
|
364
|
+
/** When true, the token cannot be saved/used and the account needs re-auth. */
|
|
365
|
+
public readonly terminal: boolean;
|
|
366
|
+
/**
|
|
367
|
+
* When set, the (already rotated) refresh token to persist before re-auth, so
|
|
368
|
+
* the caller can drive a clean re-authentication without discarding the
|
|
369
|
+
* rotation the server already performed (review #5). Only the refresh token
|
|
370
|
+
* is retained (never the access token), and the property is non-enumerable so
|
|
371
|
+
* it is not leaked by structured logging/serialization (review: credentials
|
|
372
|
+
* must not be enumerable Error properties).
|
|
373
|
+
*/
|
|
374
|
+
private readonly rotatedRefresh?: string;
|
|
375
|
+
|
|
376
|
+
constructor(
|
|
377
|
+
status: number | undefined,
|
|
378
|
+
public readonly oauthError: string | undefined,
|
|
379
|
+
message: string,
|
|
380
|
+
options?: { cause?: unknown; terminal?: boolean; credentials?: OAuthCredentials },
|
|
381
|
+
) {
|
|
382
|
+
super(message, options);
|
|
383
|
+
this.name = "NousTokenError";
|
|
384
|
+
this.terminal = options?.terminal ?? false;
|
|
385
|
+
this.rotatedRefresh = options?.credentials?.refresh;
|
|
386
|
+
// Non-enumerable so JSON.stringify / util.inspect / logging sinks do not
|
|
387
|
+
// surface a live credential. Read it via getRotatedRefresh().
|
|
388
|
+
Object.defineProperty(this, "rotatedRefresh", { enumerable: false, configurable: true });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The rotated refresh token to persist before re-auth, if any. */
|
|
392
|
+
getRotatedRefresh(): string | undefined {
|
|
393
|
+
return this.rotatedRefresh;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function requestSignal(signal: AbortSignal | undefined): AbortSignal {
|
|
398
|
+
const timeoutSignal = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS);
|
|
399
|
+
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Sleep for `ms`, resolving on timer completion. The abort listener is removed
|
|
404
|
+
* on both resolve and abort so we do not accumulate listeners across polling
|
|
405
|
+
* iterations (review point #9).
|
|
406
|
+
*/
|
|
407
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
408
|
+
return new Promise((resolve, reject) => {
|
|
409
|
+
if (signal?.aborted) return reject(new Error("Login cancelled"));
|
|
410
|
+
const onAbort = () => {
|
|
411
|
+
clearTimeout(t);
|
|
412
|
+
cleanup();
|
|
413
|
+
reject(new Error("Login cancelled"));
|
|
414
|
+
};
|
|
415
|
+
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
|
416
|
+
const t = setTimeout(() => {
|
|
417
|
+
cleanup();
|
|
418
|
+
resolve();
|
|
419
|
+
}, ms);
|
|
420
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Read an error payload from a failed token response. `payload` is the already
|
|
426
|
+
* parsed JSON (so callers do not re-read a consumed body — review point #8).
|
|
427
|
+
*/
|
|
428
|
+
function tokenErrorFromPayload(status: number, payload: unknown): NousTokenError {
|
|
429
|
+
const body = (payload ?? {}) as { error?: unknown; error_description?: unknown };
|
|
430
|
+
const oauthError = typeof body.error === "string" ? body.error : undefined;
|
|
431
|
+
const detail = typeof body.error_description === "string" ? body.error_description : "";
|
|
432
|
+
const suffix = detail ? `: ${detail}` : oauthError ? `: ${oauthError}` : "";
|
|
433
|
+
const terminal = oauthError === "invalid_token" || oauthError === "invalid_grant" || oauthError === "revoked" || oauthError === "revoked_token";
|
|
434
|
+
return new NousTokenError(status, oauthError, `Nous Portal token request failed: ${status}${suffix}`, { terminal });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Build credentials from a token endpoint response.
|
|
439
|
+
*
|
|
440
|
+
* Nous refresh tokens are SINGLE-USE and rotated on every successful refresh
|
|
441
|
+
* (see module docstring, matching Hermes `hermes_cli/auth.py`). A response
|
|
442
|
+
* that omits `refresh_token`, or returns a replacement equal to the token we
|
|
443
|
+
* just submitted, leaves us holding a consumed credential: the next refresh
|
|
444
|
+
* would replay it and the Portal treats reuse as token theft
|
|
445
|
+
* (`refresh_token_reused`), revoking the whole session. Reject both cases
|
|
446
|
+
* rather than silently falling back to the submitted token.
|
|
447
|
+
*
|
|
448
|
+
* The returned access token must also grant the `inference:invoke` scope; if it
|
|
449
|
+
* does not, the credential is unusable for inference and we raise a terminal
|
|
450
|
+
* error — but we still surface the (already rotated) refresh token in the
|
|
451
|
+
* error so the caller can persist it and drive a clean re-authentication
|
|
452
|
+
* without discarding the rotation the server already performed (review #5).
|
|
453
|
+
*
|
|
454
|
+
* @param submittedRefreshToken the refresh token sent in the request; used only
|
|
455
|
+
* to detect a no-rotation / consumed-token response, never as a fallback.
|
|
456
|
+
*/
|
|
457
|
+
function parseTokenPayload(payload: NousTokenResponse, submittedRefreshToken: string): OAuthCredentials {
|
|
458
|
+
const access = nonEmptyString(payload.access_token);
|
|
459
|
+
if (!access) {
|
|
460
|
+
// A response without a usable access token cannot be used for inference;
|
|
461
|
+
// classify it as terminal so the coordinator forces re-authentication.
|
|
462
|
+
throw new NousTokenError(
|
|
463
|
+
undefined,
|
|
464
|
+
"invalid_token",
|
|
465
|
+
"Nous Portal token response did not include an access token",
|
|
466
|
+
{ terminal: true },
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
const refresh = nonEmptyString(payload.refresh_token);
|
|
470
|
+
if (!refresh) {
|
|
471
|
+
if (submittedRefreshToken) {
|
|
472
|
+
// Rotation path: the server consumed RT-A but returned no replacement, so
|
|
473
|
+
// reusing RT-A would trigger refresh_token_reused and revoke the session.
|
|
474
|
+
throw new NousTokenError(
|
|
475
|
+
undefined,
|
|
476
|
+
"refresh_token_reused",
|
|
477
|
+
"Nous Portal did not return a replacement refresh token; refusing to reuse the consumed one (would trigger refresh_token_reused and revoke the session)",
|
|
478
|
+
{ terminal: true },
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
// Device-login path: no refresh token was submitted; a missing refresh_token
|
|
482
|
+
// in the initial token response is an unusable response, not a reuse.
|
|
483
|
+
throw new NousTokenError(
|
|
484
|
+
undefined,
|
|
485
|
+
"invalid_token",
|
|
486
|
+
"Nous Portal token response did not include a refresh token",
|
|
487
|
+
{ terminal: true },
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
if (submittedRefreshToken && refresh === submittedRefreshToken) {
|
|
491
|
+
throw new NousTokenError(
|
|
492
|
+
undefined,
|
|
493
|
+
"refresh_token_reused",
|
|
494
|
+
"Nous Portal returned the same refresh token we submitted; refusing to reuse it (single-use rotation expected, session may be compromised)",
|
|
495
|
+
{ terminal: true },
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const jwtPayload = decodeJwtPayload(access);
|
|
500
|
+
const expMs = jwtExpiryMs(jwtPayload);
|
|
501
|
+
const expiresInMs = typeof payload.expires_in === "number" ? payload.expires_in * 1000 : undefined;
|
|
502
|
+
// Prefer the JWT `exp` claim when present (it is the authoritative inference
|
|
503
|
+
// JWT lifetime), else fall back to `expires_in`.
|
|
504
|
+
const rawExpires = expMs
|
|
505
|
+
?? (expiresInMs !== undefined ? Date.now() + expiresInMs : Date.now() + DEFAULT_ACCESS_TOKEN_TTL_MS);
|
|
506
|
+
const expires = Math.max(0, rawExpires - OAUTH_EXPIRY_SKEW_MS);
|
|
507
|
+
|
|
508
|
+
const creds: OAuthCredentials = {
|
|
509
|
+
access,
|
|
510
|
+
refresh,
|
|
511
|
+
expires,
|
|
512
|
+
...identityFromNousTokens(access),
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
if (!jwtGrantsInference(jwtPayload)) {
|
|
516
|
+
// Unusable for inference, but the server already rotated the refresh token:
|
|
517
|
+
// surface it so the caller persists it and forces a re-auth rather than
|
|
518
|
+
// discarding a valid rotation.
|
|
519
|
+
throw new NousTokenError(
|
|
520
|
+
undefined,
|
|
521
|
+
"insufficient_scope",
|
|
522
|
+
"Nous Portal access token does not grant the required inference:invoke scope",
|
|
523
|
+
{ terminal: true, credentials: creds },
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return creds;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{
|
|
531
|
+
userCode: string;
|
|
532
|
+
deviceCode: string;
|
|
533
|
+
verificationUriComplete: string;
|
|
534
|
+
expiresInMs: number;
|
|
535
|
+
intervalMs: number;
|
|
536
|
+
}> {
|
|
537
|
+
const effectiveSignal = requestSignal(signal);
|
|
538
|
+
const response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/device/code`, {
|
|
539
|
+
method: "POST",
|
|
540
|
+
headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
|
|
541
|
+
body: new URLSearchParams({
|
|
542
|
+
client_id: NOUS_OAUTH_CLIENT_ID,
|
|
543
|
+
scope: NOUS_OAUTH_SCOPE,
|
|
544
|
+
}),
|
|
545
|
+
redirect: "error",
|
|
546
|
+
signal: effectiveSignal,
|
|
547
|
+
});
|
|
548
|
+
if (!response.ok) {
|
|
549
|
+
throw tokenErrorFromPayload(response.status, await readOAuthJsonOrEmpty(response, effectiveSignal));
|
|
550
|
+
}
|
|
551
|
+
// A successful HTTP response may still carry an empty/HTML/non-JSON body.
|
|
552
|
+
// Fall back to an empty object so the required-field check below produces the
|
|
553
|
+
// clear "missing required fields" validation error instead of leaking a raw
|
|
554
|
+
// JSON parser exception.
|
|
555
|
+
const payload = await readOAuthJsonOrEmpty(response, effectiveSignal) as NousDeviceAuthorizationResponse;
|
|
556
|
+
const userCode = nonEmptyString(payload.user_code);
|
|
557
|
+
const deviceCode = nonEmptyString(payload.device_code);
|
|
558
|
+
const verificationUri = nonEmptyString(payload.verification_uri_complete) ?? nonEmptyString(payload.verification_uri);
|
|
559
|
+
if (!userCode || !deviceCode || !verificationUri) {
|
|
560
|
+
throw new Error("Nous Portal device authorization response missing required fields");
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
userCode,
|
|
564
|
+
deviceCode,
|
|
565
|
+
verificationUriComplete: verificationUri,
|
|
566
|
+
expiresInMs: typeof payload.expires_in === "number" && payload.expires_in > 0
|
|
567
|
+
? payload.expires_in * 1000
|
|
568
|
+
: DEFAULT_DEVICE_FLOW_TTL_MS,
|
|
569
|
+
intervalMs: typeof payload.interval === "number" && payload.interval > 0
|
|
570
|
+
? payload.interval * 1000
|
|
571
|
+
: DEFAULT_POLL_INTERVAL_MS,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function pollForToken(
|
|
576
|
+
deviceCode: string,
|
|
577
|
+
intervalMs: number,
|
|
578
|
+
expiresInMs: number,
|
|
579
|
+
signal?: AbortSignal,
|
|
580
|
+
): Promise<OAuthCredentials> {
|
|
581
|
+
const deadline = Date.now() + expiresInMs;
|
|
582
|
+
// Deadline-aware retry wait: never sleep past the device-flow deadline, so a
|
|
583
|
+
// late retry cannot delay the expiration report or accept a stale response.
|
|
584
|
+
const sleepUntilDeadline = async (ms: number) => {
|
|
585
|
+
const remainingMs = deadline - Date.now();
|
|
586
|
+
if (remainingMs <= 0) return false;
|
|
587
|
+
await sleep(Math.min(ms, remainingMs), signal);
|
|
588
|
+
return true;
|
|
589
|
+
};
|
|
590
|
+
let waitMs = Math.max(1000, intervalMs);
|
|
591
|
+
while (Date.now() < deadline) {
|
|
592
|
+
if (signal?.aborted) throw new Error("Login cancelled");
|
|
593
|
+
let response: Response;
|
|
594
|
+
const effectiveSignal = requestSignal(signal);
|
|
595
|
+
try {
|
|
596
|
+
response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/token`, {
|
|
597
|
+
method: "POST",
|
|
598
|
+
headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
|
|
599
|
+
body: new URLSearchParams({
|
|
600
|
+
client_id: NOUS_OAUTH_CLIENT_ID,
|
|
601
|
+
device_code: deviceCode,
|
|
602
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
603
|
+
}),
|
|
604
|
+
redirect: "error",
|
|
605
|
+
signal: effectiveSignal,
|
|
606
|
+
});
|
|
607
|
+
} catch (netErr) {
|
|
608
|
+
// Genuine cancellation must abort immediately. Any other transport-level
|
|
609
|
+
// failure (timeout, DNS, dropped connection, proxy reset) must not destroy
|
|
610
|
+
// a device session that is still within its deadline: retry with the
|
|
611
|
+
// current wait interval. The device-code grant is idempotent for the
|
|
612
|
+
// pending case, so a retried poll is safe (unlike the refresh path).
|
|
613
|
+
if (signal?.aborted) throw new Error("Login cancelled");
|
|
614
|
+
if (await sleepUntilDeadline(waitMs)) continue;
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
// Parse under the same deadline that covered the request headers. Keep the
|
|
618
|
+
// read outside the fetch retry catch: a bounded-reader error or caller
|
|
619
|
+
// cancellation is an observed response failure, not a safe poll retry.
|
|
620
|
+
const payload = await readOAuthJsonOrEmpty(response, effectiveSignal) as NousTokenResponse;
|
|
621
|
+
// Parse once and pass the payload through to the failure path (review #8),
|
|
622
|
+
// so we never try to re-read a body that has already been consumed.
|
|
623
|
+
// Normalize a successful-but-non-object body (for example valid JSON
|
|
624
|
+
// `null`) to an empty object so the required-field validation below
|
|
625
|
+
// produces a terminal NousTokenError instead of a raw TypeError.
|
|
626
|
+
if (Date.now() >= deadline) break;
|
|
627
|
+
if (response.ok) return parseTokenPayload(payload, "");
|
|
628
|
+
const error = payload.error;
|
|
629
|
+
if (error === "authorization_pending") {
|
|
630
|
+
if (!(await sleepUntilDeadline(waitMs))) break;
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (error === "slow_down") {
|
|
634
|
+
waitMs = Math.min(MAX_POLL_INTERVAL_MS, waitMs + 5000);
|
|
635
|
+
const retryAfter = typeof payload.interval === "number" ? payload.interval * 1000 : undefined;
|
|
636
|
+
if (retryAfter && retryAfter > waitMs) waitMs = Math.min(MAX_POLL_INTERVAL_MS, retryAfter);
|
|
637
|
+
if (!(await sleepUntilDeadline(waitMs))) break;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (error === "expired_token") {
|
|
641
|
+
throw new NousTokenError(response.status, "expired_token", "Nous Portal device authorization expired", { terminal: true });
|
|
642
|
+
}
|
|
643
|
+
if (error === "access_denied") {
|
|
644
|
+
throw new NousTokenError(response.status, "access_denied", "Nous Portal device authorization denied", { terminal: true });
|
|
645
|
+
}
|
|
646
|
+
// Unknown OAuth error: report it from the parsed payload, not by
|
|
647
|
+
// re-reading the (already consumed) response body.
|
|
648
|
+
if (error) {
|
|
649
|
+
const detail = typeof payload.error_description === "string" ? `: ${payload.error_description}` : "";
|
|
650
|
+
throw new NousTokenError(response.status, String(error), `Nous Portal device authorization failed (${error})${detail}`);
|
|
651
|
+
}
|
|
652
|
+
throw tokenErrorFromPayload(response.status, payload);
|
|
653
|
+
}
|
|
654
|
+
throw new NousTokenError(undefined, "expired_token", "Nous Portal device flow timed out");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export async function loginNous(ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
658
|
+
const device = await requestDeviceAuthorization(ctrl.signal);
|
|
659
|
+
ctrl.onAuth?.({
|
|
660
|
+
url: device.verificationUriComplete,
|
|
661
|
+
instructions: `Sign in to Nous Portal and enter the code: ${device.userCode}`,
|
|
662
|
+
deviceCode: device.userCode,
|
|
663
|
+
});
|
|
664
|
+
return pollForToken(device.deviceCode, device.intervalMs, device.expiresInMs, ctrl.signal);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Refresh a Nous Portal session. The refresh token travels in the
|
|
669
|
+
* `x-nous-refresh-token` header; the server rotates it on every successful
|
|
670
|
+
* refresh, and the rotated token is what the caller persists.
|
|
671
|
+
*
|
|
672
|
+
* Failure-atomicity contract (review blocker #2): a durable refresh-intent is
|
|
673
|
+
* recorded before the request and cleared only after the rotated token is
|
|
674
|
+
* obtained. After dispatch, every non-2xx response (including 429 and unknown
|
|
675
|
+
* 4xx) leaves the intent "uncertain" — a client-class status does not prove the
|
|
676
|
+
* single-use token was not consumed, so the old token stays blocked and a later
|
|
677
|
+
* refresh refuses to replay it, forcing re-auth instead of a session-revoking
|
|
678
|
+
* replay. The intent is cleared only after the rotated credential is durably
|
|
679
|
+
* persisted by the store.
|
|
680
|
+
*/
|
|
681
|
+
export async function refreshNousToken(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials> {
|
|
682
|
+
// Validate the OAuth base URL first (independent of the token): a malformed
|
|
683
|
+
// or non-HTTPS override must fail before we record any refresh intent, so a
|
|
684
|
+
// bad URL never leaves a "submitted" intent behind (which would otherwise
|
|
685
|
+
// make the next call refuse to replay the token).
|
|
686
|
+
const baseUrl = resolvePortalBaseUrl();
|
|
687
|
+
// Never blindly replay a token whose outcome we could not confirm earlier:
|
|
688
|
+
// a prior submission got a server response (so it may have been consumed) or
|
|
689
|
+
// failed to persist its rotation. Replaying it could trigger
|
|
690
|
+
// `refresh_token_reused` and revoke the session.
|
|
691
|
+
if (nousRefreshIntentBlocksReplay(refreshToken)) {
|
|
692
|
+
throw new NousTokenError(
|
|
693
|
+
undefined,
|
|
694
|
+
"refresh_token_reused",
|
|
695
|
+
"Refusing to replay a refresh token with an unconfirmed prior outcome (previous rotation may not have persisted)",
|
|
696
|
+
{ terminal: true },
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
// Record that we are about to submit this token. It stays "submitted" after a
|
|
700
|
+
// successful rotation until the store confirms persistence of the rotated
|
|
701
|
+
// token (via clearNousRefreshIntent) — so a crash before persistence leaves
|
|
702
|
+
// the old token refused on replay instead of silently reused.
|
|
703
|
+
// Fail-closed: if we cannot durably record the intent, refuse the refresh.
|
|
704
|
+
try {
|
|
705
|
+
writeRefreshIntent(refreshToken, "submitted");
|
|
706
|
+
} catch (ioErr) {
|
|
707
|
+
// The write happens BEFORE dispatch, so the credential has not been
|
|
708
|
+
// rejected or consumed. Fail closed (abort the refresh) but surface a
|
|
709
|
+
// NON-terminal operational error: the coordinator must not mark the account
|
|
710
|
+
// needsReauth merely because local persistence infrastructure is broken.
|
|
711
|
+
throw new RefreshIntentIOError(
|
|
712
|
+
"Refusing refresh: could not durably record the refresh-intent guard (fail-closed)",
|
|
713
|
+
ioErr,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
let response: Response;
|
|
718
|
+
const effectiveSignal = requestSignal(signal);
|
|
719
|
+
try {
|
|
720
|
+
response = await fetch(`${baseUrl}/api/oauth/token`, {
|
|
721
|
+
method: "POST",
|
|
722
|
+
headers: {
|
|
723
|
+
Accept: "application/json",
|
|
724
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
725
|
+
"x-nous-refresh-token": refreshToken,
|
|
726
|
+
},
|
|
727
|
+
body: new URLSearchParams({
|
|
728
|
+
grant_type: "refresh_token",
|
|
729
|
+
client_id: NOUS_OAUTH_CLIENT_ID,
|
|
730
|
+
}),
|
|
731
|
+
redirect: "error",
|
|
732
|
+
signal: effectiveSignal,
|
|
733
|
+
});
|
|
734
|
+
} catch (netErr) {
|
|
735
|
+
// The request may have reached the server and rotated the token even on a
|
|
736
|
+
// timeout/abort/connection error — we cannot prove it did NOT. Mark the
|
|
737
|
+
// intent uncertain so the submitted token is never replayed; the next
|
|
738
|
+
// refresh will force a clean re-auth instead of risking reuse.
|
|
739
|
+
try {
|
|
740
|
+
writeRefreshIntent(refreshToken, "uncertain");
|
|
741
|
+
} catch {
|
|
742
|
+
// If we cannot even mark uncertain, the worst case is a later blind
|
|
743
|
+
// replay; prefer surfacing the original network error so it is retried
|
|
744
|
+
// through the normal path, which will re-encounter the guard if the file
|
|
745
|
+
// later becomes readable.
|
|
746
|
+
}
|
|
747
|
+
throw netErr;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (!response.ok) {
|
|
751
|
+
const status = response.status;
|
|
752
|
+
// The request reached the Portal's token endpoint. A non-2xx response does
|
|
753
|
+
// NOT establish that the single-use refresh token was not consumed: 429
|
|
754
|
+
// rate limits, unknown/custom 4xx, and gateway-generated client-class
|
|
755
|
+
// errors can all be returned AFTER the remote side processed (and consumed)
|
|
756
|
+
// the token. Only a response whose documented OAuth semantics prove
|
|
757
|
+
// non-consumption would be safe to retry, and no such Nous contract is
|
|
758
|
+
// documented (invalid_grant/refresh_token_reused are explicitly terminal).
|
|
759
|
+
// Fail closed: retain the durable intent as uncertain so the submitted
|
|
760
|
+
// token is never automatically replayed.
|
|
761
|
+
try {
|
|
762
|
+
writeRefreshIntent(refreshToken, "uncertain");
|
|
763
|
+
} catch {
|
|
764
|
+
// The pre-dispatch "submitted" intent is still on disk, which also
|
|
765
|
+
// blocks replay; surface the original HTTP error below.
|
|
766
|
+
}
|
|
767
|
+
const payload = await readOAuthJsonOrEmpty(response, effectiveSignal);
|
|
768
|
+
throw tokenErrorFromPayload(status, payload);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// The server responded 200 — the submitted token may now be consumed. If we
|
|
772
|
+
// fail to parse the rotated token, mark the intent uncertain so we never
|
|
773
|
+
// replay it. On success we deliberately LEAVE the intent as "submitted"
|
|
774
|
+
// (the store clears it once the rotated token is persisted).
|
|
775
|
+
try {
|
|
776
|
+
const creds = parseTokenPayload(
|
|
777
|
+
(await readOAuthJson(response, effectiveSignal)) as NousTokenResponse,
|
|
778
|
+
refreshToken,
|
|
779
|
+
);
|
|
780
|
+
return creds;
|
|
781
|
+
} catch (e) {
|
|
782
|
+
try {
|
|
783
|
+
writeRefreshIntent(refreshToken, "uncertain");
|
|
784
|
+
} catch {
|
|
785
|
+
// already throwing the parse error below; do not mask it
|
|
786
|
+
}
|
|
787
|
+
throw e;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Clear the durable refresh-intent for a token. The account store calls this
|
|
793
|
+
* after `mergeAccountCredential` persists the rotated token, closing the
|
|
794
|
+
* uncertain-outcome window opened by `refreshNousToken`.
|
|
795
|
+
*/
|
|
796
|
+
export function clearNousRefreshIntent(refreshToken: string): void {
|
|
797
|
+
clearRefreshIntent(refreshToken);
|
|
798
|
+
}
|