heyspex 0.1.0 → 0.2.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 +21 -0
- package/bin/hs.mjs +931 -98
- package/bin/spexcode.mjs +262 -0
- package/dev-factory/engine/worktree.mjs +740 -0
- package/dev-factory/lib/code-repo-root.mjs +67 -0
- package/dev-factory/lib/context-budget.mjs +84 -0
- package/dev-factory/lib/epic-detect.mjs +190 -0
- package/dev-factory/lib/fleet-queue-file.mjs +846 -0
- package/dev-factory/lib/fleet-queue.mjs +426 -0
- package/dev-factory/lib/fleet-state.mjs +14 -18
- package/dev-factory/lib/model-catalog.mjs +338 -0
- package/dev-factory/lib/seat-context.mjs +199 -0
- package/dev-factory/lib/spec-io.mjs +93 -0
- package/dev-factory/lib/sufficiency-score.mjs +8 -2
- package/dev-factory/lib/worktree-allocator.mjs +744 -0
- package/dev-factory/model-catalog.json +222 -0
- package/dist/cli/hs-check.mjs +1085 -103
- package/dist/cli/hs-claim.mjs +936 -63
- package/dist/cli/hs-connect.mjs +9786 -1910
- package/dist/cli/hs-defer.mjs +5267 -3053
- package/dist/cli/hs-dev.mjs +564 -0
- package/dist/cli/hs-env.mjs +385 -299
- package/dist/cli/hs-index-rebuild.mjs +7129 -0
- package/dist/cli/hs-index-reconcile.mjs +7852 -0
- package/dist/cli/hs-init.mjs +777 -138
- package/dist/cli/hs-install.mjs +6583 -485
- package/dist/cli/hs-join.mjs +10137 -2368
- package/dist/cli/hs-vault-pull.mjs +4659 -5882
- package/dist/cli/hs-vault.mjs +3793 -1537
- package/dist/cli/hs-workspace.mjs +2550 -524
- package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
- package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
- package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
- package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
- package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
- package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
- package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
- package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +231 -124
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
- package/plugins/heyspex/.mcp.json +12 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +62 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
- package/plugins/heyspex/hooks/hooks.json +361 -0
- package/plugins/heyspex/mcp.json +13 -0
- package/scripts/check-mcp-freshness.mjs +17 -9
- package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
- package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
- package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
- package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
- package/scripts/claude-hooks/_run.mjs +120 -0
- package/scripts/claude-hooks/_transcript-text.mjs +60 -0
- package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
- package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
- package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
- package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
- package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
- package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
- package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
- package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
- package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
- package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
- package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
- package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
- package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
- package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
- package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
- package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
- package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
- package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
- package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
- package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
- package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
- package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
- package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
- package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
- package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
- package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
- package/scripts/claude-hooks/log-session-activity.mjs +341 -0
- package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
- package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
- package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
- package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
- package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
- package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
- package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
- package/scripts/claude-hooks/research-method-guard.mjs +398 -0
- package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
- package/scripts/claude-hooks/session-end.mjs +449 -0
- package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
- package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
- package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
- package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
- package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
- package/scripts/cursor-hooks/manifest.mjs +235 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +268 -0
- package/scripts/hs-auto-resolve.mjs +240 -0
- package/scripts/hs-auto-source.mjs +398 -0
- package/scripts/hs-auto.mjs +134 -0
- package/scripts/hs-capture-cli.mjs +326 -0
- package/scripts/hs-classifier.mjs +136 -0
- package/scripts/hs-companion.mjs +88 -0
- package/scripts/hs-gateway.mjs +115 -0
- package/scripts/hs-mode.mjs +125 -0
- package/scripts/hs-next.mjs +441 -0
- package/scripts/hs-settings.mjs +338 -0
- package/scripts/hs-view.mjs +333 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +270 -0
- package/scripts/install-cursor-hooks.mjs +471 -0
- package/scripts/lib/agent-env-markers.mjs +38 -0
- package/scripts/lib/console-url-resolver.mjs +197 -60
- package/scripts/lib/customer-kit.mjs +886 -0
- package/scripts/lib/dev-mode.mjs +244 -28
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-commit.mjs +59 -5
- package/scripts/lib/hs-source-identity.mjs +526 -0
- package/scripts/lib/hs-source-log.mjs +11 -21
- package/scripts/lib/hs-source-push.mjs +107 -58
- package/scripts/lib/hs-worktree-args.mjs +115 -0
- package/scripts/lib/shared-path-helpers.mjs +134 -0
- package/scripts/lib/standalone-doctor.mjs +540 -3
- package/scripts/lib/subagent-git-lock.mjs +109 -0
- package/scripts/lib/template-pin.mjs +34 -0
- package/scripts/provenance-lib.mjs +91 -0
- package/scripts/stamp-provenance-on-edit.mjs +222 -0
- package/scripts/wip-ledger-health.mjs +653 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
- package/spexharness/src/adapters/audit.mjs +197 -0
- package/spexharness/src/adapters/claude-code.mjs +441 -0
- package/spexharness/src/chat/tui/ansi.mjs +448 -0
- package/spexharness/src/chat/tui/palette.mjs +22 -0
- package/spexharness/src/chat/tui/select.mjs +208 -0
- package/spexharness/src/config-root.mjs +54 -0
- package/spexharness/src/context-budget.mjs +141 -0
- package/spexharness/src/dev-server-control.mjs +389 -0
- package/spexharness/src/doctrine/behaviour.mjs +371 -0
- package/spexharness/src/doctrine/corpus.mjs +733 -0
- package/spexharness/src/doctrine/inject.mjs +338 -0
- package/spexharness/src/doctrine/merge.mjs +184 -0
- package/spexharness/src/doctrine/sources.mjs +252 -0
- package/spexharness/src/doctrine/synthesize.mjs +401 -0
- package/spexharness/src/doctrine/vault-source.mjs +574 -0
- package/spexharness/src/doctrine/writer.mjs +176 -0
- package/spexharness/src/events.mjs +84 -0
- package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
- package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
- package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
- package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
- package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
- package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
- package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
- package/spexharness/src/gates/git-identity-guard.mjs +61 -0
- package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
- package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
- package/spexharness/src/gates/git-stash-gate.mjs +159 -0
- package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
- package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
- package/spexharness/src/gates/inject-vault-context.mjs +239 -0
- package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
- package/spexharness/src/gates/plan-review-gate.mjs +233 -0
- package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
- package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
- package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
- package/spexharness/src/gates/settings-write-gate.mjs +104 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +194 -0
- package/spexharness/src/intent-directive.mjs +541 -0
- package/spexharness/src/profile/learn.mjs +551 -0
- package/spexharness/src/profile/proposals.mjs +357 -0
- package/spexharness/src/profile/vault-inclusion.mjs +386 -0
- package/spexharness/src/redact.mjs +151 -0
- package/spexharness/src/rule-table.mjs +819 -0
- package/spexharness/src/seat-context.mjs +122 -0
- package/spexharness/src/session-bootstrap.mjs +512 -0
- package/spexharness/src/session-record.mjs +815 -0
- package/spexharness/src/token-inventory.mjs +207 -0
- package/vendor/heyspex-mcp/account-switch.mjs +95 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
- package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
- package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
- package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
- package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
- package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
- package/vendor/heyspex-mcp/cli.mjs +201 -15524
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
- package/vendor/heyspex-mcp/config-seed.yaml +15 -0
- package/vendor/heyspex-mcp/config-settings.mjs +290 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
- package/vendor/heyspex-mcp/console-transport.mjs +166 -0
- package/vendor/heyspex-mcp/credentials.mjs +584 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
- package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
- package/vendor/heyspex-mcp/design-tools.mjs +4 -1
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
- package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
- package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
- package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
- package/vendor/heyspex-mcp/events-tools.mjs +207 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
- package/vendor/heyspex-mcp/http-transport.mjs +300 -0
- package/vendor/heyspex-mcp/list-tools.mjs +257 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
- package/vendor/heyspex-mcp/package-root.mjs +32 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
- package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
- package/vendor/heyspex-mcp/plan-push.mjs +906 -21
- package/vendor/heyspex-mcp/plan-review.mjs +124 -30
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
- package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
- package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
- package/vendor/heyspex-mcp/promote-research.mjs +660 -53
- package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
- package/vendor/heyspex-mcp/prompts.mjs +1460 -0
- package/vendor/heyspex-mcp/read-path.mjs +29 -9
- package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
- package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
- package/vendor/heyspex-mcp/register-spec.mjs +266 -54
- package/vendor/heyspex-mcp/request-context.mjs +59 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
- package/vendor/heyspex-mcp/research-tools.mjs +4 -4
- package/vendor/heyspex-mcp/response-budget.mjs +348 -0
- package/vendor/heyspex-mcp/review-focus.mjs +25 -1
- package/vendor/heyspex-mcp/search-specs.mjs +280 -0
- package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
- package/vendor/heyspex-mcp/session-owner.mjs +108 -0
- package/vendor/heyspex-mcp/session-tools.mjs +152 -0
- package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +301 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
- package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
- package/vendor/heyspex-mcp/vault-io.mjs +724 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +91 -0
|
@@ -25,11 +25,60 @@
|
|
|
25
25
|
* it to every outbound request — `consoleFetch` AND `postClaimEndpoint` share
|
|
26
26
|
* one transport, so both get it for free. No token resolvable → no header,
|
|
27
27
|
* exactly today's (unauthenticated) behavior against a local dev console.
|
|
28
|
+
*
|
|
29
|
+
* SIGN-IN vs OUTAGE ([[HS-9R6S-N8VP-DIAG-capture-writes-report-the-console-as]]):
|
|
30
|
+
* a refused connection is NOT proof the console is down — it is equally the
|
|
31
|
+
* symptom of talking to the wrong console. After recovery has failed, a
|
|
32
|
+
* status-0 result is re-classified against the resolved reliance
|
|
33
|
+
* (scripts/lib/dev-mode.mjs) + the resolved bearer token:
|
|
34
|
+
*
|
|
35
|
+
* - cloud reliance + loopback URL → `CONSOLE_LOCAL_URL_IN_CLOUD_MODE`
|
|
36
|
+
* - cloud reliance + no token → `CONSOLE_NOT_SIGNED_IN`
|
|
37
|
+
* - anything else → `CONSOLE_UNREACHABLE` (unchanged)
|
|
38
|
+
* - an answered HTTP 401 → `CONSOLE_NOT_SIGNED_IN`
|
|
39
|
+
*
|
|
40
|
+
* Both new classes carry `authProblem` on the result. Callers MUST NOT treat
|
|
41
|
+
* an `authProblem` result as bufferable: nothing becomes reachable until the
|
|
42
|
+
* user runs `hs login`, so stashing to the offline buffer would hide the real
|
|
43
|
+
* cause behind a false "your words will sync" promise. The 4xx →
|
|
44
|
+
* CAPTURE_CONFLICT / 5xx → PATCH_FAILED contract is untouched — both still
|
|
45
|
+
* mean "the server ANSWERED", and neither buffers; 401 only gets a more
|
|
46
|
+
* specific code and an actionable message inside that same never-buffer family.
|
|
47
|
+
*
|
|
48
|
+
* EXPLICIT SCOPE ([[HS-MVG4-WW9R-TASK-mcp-and-hs-name-account-vault-on-every]],
|
|
49
|
+
* invariant 2 of [[HS-BIWR-WW9R-EPIC-explicit-vault-scope-on-every-console]]):
|
|
50
|
+
* every vault-scoped request names `{ account, vault }` on the wire —
|
|
51
|
+
* `x-heyspex-vault-id` from the resolver (ANY source, the registry pointer
|
|
52
|
+
* included) and `x-heyspex-account-id` from the one account reader
|
|
53
|
+
* (session-owner.mjs). A cwd that resolves to an UNREGISTERED root, or a
|
|
54
|
+
* machine with no configured account, is refused LOCALLY before any fetch:
|
|
55
|
+
* `consoleFetch` / `postClaimEndpoint` return a status-0 result carrying
|
|
56
|
+
* `refusal: VAULT_NOT_REGISTERED | ACCOUNT_NOT_CONFIGURED` (also stamped as
|
|
57
|
+
* `authProblem`, so every existing never-buffer path treats it the same way a
|
|
58
|
+
* sign-in problem is treated) and a message naming `hs connect` / `hs login`.
|
|
59
|
+
* The endpoints in {@link VAULT_LESS_ENDPOINTS} (pairing, session identity,
|
|
60
|
+
* creating/listing vaults and workspaces, dev-mode) are exempt: headers are
|
|
61
|
+
* attached when they resolve, and nothing refuses when they do not.
|
|
28
62
|
*/
|
|
29
63
|
import { readFileSync } from 'node:fs';
|
|
30
64
|
import { homedir } from 'node:os';
|
|
31
65
|
import { join } from 'node:path';
|
|
32
66
|
|
|
67
|
+
import { readBearer } from './credentials.mjs';
|
|
68
|
+
import { resolveSessionOwnerUncached } from './session-owner.mjs';
|
|
69
|
+
import { isVaultLessEndpoint, VAULT_LESS_ENDPOINTS } from './vault-less-endpoints.mjs';
|
|
70
|
+
|
|
71
|
+
export { isVaultLessEndpoint, VAULT_LESS_ENDPOINTS };
|
|
72
|
+
|
|
73
|
+
// The runnable form of a HeySpex command on THIS machine — `hs login` when the
|
|
74
|
+
// bin is on PATH, else an absolute-path `node "<install>/bin/hs.mjs" login`.
|
|
75
|
+
// Never hard-code the bare `hs …` spelling in text a human reads, and never an
|
|
76
|
+
// `npx` form: npx resolves from the READER's working directory, and outside a
|
|
77
|
+
// checkout `npx hs` installs the unrelated public package hs@2.0.1
|
|
78
|
+
// ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
|
|
79
|
+
import { hsCommand } from '../../scripts/lib/hs-command.mjs';
|
|
80
|
+
import { getActiveConfig, UNREGISTERED_VAULT_ID } from './vault-resolve.mjs';
|
|
81
|
+
|
|
33
82
|
export const DEFAULT_CONSOLE_TIMEOUT_MS = 30000;
|
|
34
83
|
|
|
35
84
|
/** Backoff when Next dev restarts (next.config change, etc.) — ECONNREFUSED is transient. */
|
|
@@ -39,6 +88,26 @@ function sleep(ms) {
|
|
|
39
88
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
40
89
|
}
|
|
41
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Cold cloud lambda ([[HS-2FQY]] read side): the console answers 503
|
|
93
|
+
* `vault_scope_unresolved` (+ `Retry-After`) while it has not resolved its
|
|
94
|
+
* active vault yet, instead of a 200 empty bucket that reads as "the vault is
|
|
95
|
+
* empty". Transient by contract — the console kicks its own self-heal — so
|
|
96
|
+
* the client waits the advertised window (capped) and tries once more.
|
|
97
|
+
*/
|
|
98
|
+
export const VAULT_SCOPE_RETRY_CAP_MS = 5000;
|
|
99
|
+
export const VAULT_SCOPE_RETRY_DEFAULT_MS = 3000;
|
|
100
|
+
|
|
101
|
+
export function isVaultScopeUnresolved(result) {
|
|
102
|
+
return result?.status === 503 && result?.body?.error === 'vault_scope_unresolved';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function retryAfterMs(result) {
|
|
106
|
+
const raw = Number(result?.retryAfterSeconds);
|
|
107
|
+
if (!Number.isFinite(raw) || raw <= 0) return VAULT_SCOPE_RETRY_DEFAULT_MS;
|
|
108
|
+
return Math.min(raw * 1000, VAULT_SCOPE_RETRY_CAP_MS);
|
|
109
|
+
}
|
|
110
|
+
|
|
42
111
|
/**
|
|
43
112
|
* Detect AbortError from a thrown fetch error. Covers the standard
|
|
44
113
|
* `AbortError` name and the substring fallback some runtimes use
|
|
@@ -98,26 +167,415 @@ export function rewriteUrlOrigin(urlString, originUrl) {
|
|
|
98
167
|
return target.href;
|
|
99
168
|
}
|
|
100
169
|
|
|
170
|
+
/** Local refusal: the repo records an account this machine holds no token for. */
|
|
171
|
+
export const ACCOUNT_TOKEN_MISSING = 'account_token_missing';
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Thrown by {@link getAuthHeader} when the repo's heyspex.yaml records
|
|
175
|
+
* `account: <A>` and this machine stores no token for A
|
|
176
|
+
* ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]], invariant 4). The
|
|
177
|
+
* transport refuses every console call with it rather than sending the
|
|
178
|
+
* CURRENT account's token — that fallback is exactly how a repo connected as
|
|
179
|
+
* A wrote as B after `hs login --switch-account B`. Names `hs login --account A`.
|
|
180
|
+
*/
|
|
181
|
+
export class AccountTokenMissingError extends Error {
|
|
182
|
+
/** @param {string} accountId the account the repo records */
|
|
183
|
+
constructor(accountId) {
|
|
184
|
+
super(
|
|
185
|
+
`This repo is connected under HeySpex account ${accountId} (its heyspex.yaml \`account:\`), `
|
|
186
|
+
+ 'but this machine holds no sign-in for that account, so the console call was refused before it was sent. '
|
|
187
|
+
+ `Run \`${hsCommand(`login --account ${accountId}`)}\` to sign in as it, then try again. `
|
|
188
|
+
+ 'Nothing was sent, and the current account was NOT used in its place.',
|
|
189
|
+
);
|
|
190
|
+
this.name = 'AccountTokenMissingError';
|
|
191
|
+
this.code = ACCOUNT_TOKEN_MISSING;
|
|
192
|
+
this.accountId = accountId;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// `account:` line of a repo's heyspex.yaml — the account `hs connect` recorded
|
|
197
|
+
// the repo under. Same line-anchored shape as {@link MARKER_VAULT_ID_RE} and
|
|
198
|
+
// as init.ts `parseAccountId` / install-identity.ts `parseMarkerAccount`.
|
|
199
|
+
const MARKER_ACCOUNT_RE = /^account:[ \t]*["']?([^"'\r\n#]+)/m;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The `account` a repo's `heyspex.yaml` records, or `null` (no marker, no
|
|
203
|
+
* key, unreadable). Never throws. `readFile` is a test seam.
|
|
204
|
+
* @param {string | null | undefined} repoRootAbs
|
|
205
|
+
* @param {{ readFile?: (p: string) => string }} [opts]
|
|
206
|
+
*/
|
|
207
|
+
export function readMarkerAccount(repoRootAbs, { readFile } = {}) {
|
|
208
|
+
if (typeof repoRootAbs !== 'string' || !repoRootAbs.trim()) return null;
|
|
209
|
+
const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
|
|
210
|
+
try {
|
|
211
|
+
const raw = read(join(repoRootAbs, 'heyspex.yaml'));
|
|
212
|
+
const value = String(raw ?? '').match(MARKER_ACCOUNT_RE)?.[1]?.trim() ?? '';
|
|
213
|
+
return value || null;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The account THIS process's repo records: the resolver's root (memoized
|
|
221
|
+
* `getActiveConfig`) → its heyspex.yaml `account:`. Read per call, so an `hs
|
|
222
|
+
* connect` backfill is seen without a restart. `null` when the resolver
|
|
223
|
+
* fails or the marker records none (a legacy marker: the current account
|
|
224
|
+
* applies, unchanged).
|
|
225
|
+
* @returns {string | null}
|
|
226
|
+
*/
|
|
227
|
+
export function getRepoAccountId() {
|
|
228
|
+
let resolved;
|
|
229
|
+
try {
|
|
230
|
+
resolved = getActiveConfig();
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
return readMarkerAccount(resolved?.repoRootAbs);
|
|
235
|
+
}
|
|
236
|
+
|
|
101
237
|
/**
|
|
102
238
|
* Resolve the outbound `Authorization` header, or `{}` when no token
|
|
103
239
|
* resolves (no header at all — matches today's unauthenticated behavior
|
|
104
240
|
* against a local dev console). Precedence: `HEYSPEX_MCP_TOKEN` env →
|
|
105
|
-
* `~/.heyspex/credentials.yaml`
|
|
106
|
-
*
|
|
241
|
+
* `~/.heyspex/credentials.yaml` — read by the ONE shared bearer reader
|
|
242
|
+
* (`./credentials.mjs`, [[HS-HL0D-WW9R-TASK-share-one-bearer-reader-and-one-console]]),
|
|
243
|
+
* never re-implemented here.
|
|
244
|
+
*
|
|
245
|
+
* Which stored token: the REPO's account when its heyspex.yaml records one
|
|
246
|
+
* (`repoAccountId`, default {@link getRepoAccountId}), else the current
|
|
247
|
+
* account ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]]). A recorded
|
|
248
|
+
* account with no stored token throws {@link AccountTokenMissingError} — never
|
|
249
|
+
* a silent fall back to the current account. Injectable `env`/`homeDir`/
|
|
250
|
+
* `repoAccountId` for tests (`null` = "the repo records none"); an unreadable
|
|
251
|
+
* credentials file is just "no token".
|
|
252
|
+
* @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, repoAccountId?: string | null }} [opts]
|
|
253
|
+
*/
|
|
254
|
+
export function getAuthHeader({ env = process.env, homeDir = homedir(), repoAccountId } = {}) {
|
|
255
|
+
const accountId = repoAccountId === undefined ? getRepoAccountId() : repoAccountId;
|
|
256
|
+
const { token } = readBearer({ env, homeDir, accountId });
|
|
257
|
+
if (!token && accountId) throw new AccountTokenMissingError(accountId);
|
|
258
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* VAULT SCOPING ([[HS-M95C]]): a cloud console scopes every request to the
|
|
263
|
+
* account's active-vault POINTER unless the request names a vault. The MCP
|
|
264
|
+
* server resolves its vault locally (vault-resolve.mjs, five tiers) but never
|
|
265
|
+
* put that id on the wire, so a session started in repo A read/wrote vault B.
|
|
266
|
+
*
|
|
267
|
+
* Contract (agreed with the console side, revised by
|
|
268
|
+
* [[HS-MVG4-WW9R-TASK-mcp-and-hs-name-account-vault-on-every]]): send
|
|
269
|
+
* `x-heyspex-vault-id: <id>` for EVERY registered id the resolver produced,
|
|
270
|
+
* whatever its source — a repo-derived tier (`explicit`, `env-vault-root`,
|
|
271
|
+
* `env-vault-dir`, `satellite-pin`, `cwd-heuristic`) AND the registry's active
|
|
272
|
+
* pointer (`account-pointer`). The pointer used to send nothing so the cloud
|
|
273
|
+
* console could fall back to ITS pointer; the two pointers drift, and a silent
|
|
274
|
+
* fallback is exactly what served repo A's session from vault B. An
|
|
275
|
+
* UNREGISTERED root is not a "no header" case either: it throws
|
|
276
|
+
* {@link VaultNotRegisteredError} so the transport refuses before any fetch.
|
|
107
277
|
*/
|
|
108
|
-
export
|
|
109
|
-
|
|
110
|
-
|
|
278
|
+
export const VAULT_ID_HEADER = 'x-heyspex-vault-id';
|
|
279
|
+
/** The account the client believes it acts as; the console refuses a mismatch with 409 `ACCOUNT_MISMATCH`. */
|
|
280
|
+
export const ACCOUNT_ID_HEADER = 'x-heyspex-account-id';
|
|
111
281
|
|
|
282
|
+
/** Local refusal: the cwd resolves to a root no registry row names. */
|
|
283
|
+
export const VAULT_NOT_REGISTERED = 'VAULT_NOT_REGISTERED';
|
|
284
|
+
/** Local refusal: this machine has no configured account (`hs login` never ran). */
|
|
285
|
+
export const ACCOUNT_NOT_CONFIGURED = 'ACCOUNT_NOT_CONFIGURED';
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Thrown by {@link vaultIdHeaderFor} / {@link getVaultIdHeader} when the
|
|
289
|
+
* resolver landed on the `unregistered` sentinel (`reason: 'unregistered'`),
|
|
290
|
+
* or on a registered row whose id is a LEGACY slug with no cloud UUID to
|
|
291
|
+
* stand in for it (`reason: 'legacy-id'`, see {@link cloudVaultIdFor}).
|
|
292
|
+
* `code` is stable for machine matching; the message names the resolved root
|
|
293
|
+
* and the runnable `hs connect` form for this machine.
|
|
294
|
+
*/
|
|
295
|
+
export class VaultNotRegisteredError extends Error {
|
|
296
|
+
/**
|
|
297
|
+
* @param {string | null | undefined} repoRootAbs the root the resolver settled on
|
|
298
|
+
* @param {string | null | undefined} source the resolver tier that produced it
|
|
299
|
+
* @param {{ reason?: 'unregistered' | 'legacy-id', legacyId?: string | null }} [detail]
|
|
300
|
+
*/
|
|
301
|
+
constructor(repoRootAbs, source, { reason = 'unregistered', legacyId = null } = {}) {
|
|
302
|
+
const root = typeof repoRootAbs === 'string' && repoRootAbs.trim() ? repoRootAbs.trim() : null;
|
|
303
|
+
const where = root ? ` (${root})` : '';
|
|
304
|
+
// Names the verb (`hs connect`) AND the form that runs on this machine
|
|
305
|
+
// (`hsCommand`, [[HS-I6HT]]) — identical when the bin is on PATH.
|
|
306
|
+
const why =
|
|
307
|
+
reason === 'legacy-id'
|
|
308
|
+
? `This repo${where} is registered on this machine under a legacy id (\`${legacyId}\`), not the vault id the console knows, and its heyspex.yaml carries no \`vault_id\` to stand in for it, `
|
|
309
|
+
: `This repo${where} is not connected to a HeySpex vault on this machine (\`hs connect\` has not registered it), `;
|
|
310
|
+
super(
|
|
311
|
+
why
|
|
312
|
+
+ 'so the console call was refused before it was sent. '
|
|
313
|
+
+ `Run \`${hsCommand('connect')}\`${root ? ` in ${root}` : ''} to ${reason === 'legacy-id' ? 're-register' : 'connect'} it, then try again. Nothing was sent.`,
|
|
314
|
+
);
|
|
315
|
+
this.name = 'VaultNotRegisteredError';
|
|
316
|
+
this.code = VAULT_NOT_REGISTERED;
|
|
317
|
+
this.reason = reason;
|
|
318
|
+
this.repoRootAbs = root;
|
|
319
|
+
this.source = typeof source === 'string' ? source : null;
|
|
320
|
+
this.legacyId = legacyId;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The shape of every vault id the console mints (`randomUUID()`); a registry row that is not one is a legacy slug. */
|
|
325
|
+
const CLOUD_VAULT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
326
|
+
|
|
327
|
+
export function isCloudVaultId(value) {
|
|
328
|
+
return typeof value === 'string' && CLOUD_VAULT_ID_RE.test(value.trim());
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// `vault_id:` line of a repo's heyspex.yaml — the id the console minted and
|
|
332
|
+
// `hs init` / `hs connect` wrote back ([[HS-6CFU-TASK-write-minted-vault-id-back-into-heyspex]]).
|
|
333
|
+
// Same minimal line-anchored shape as session-owner.mjs's `account_id:` reader.
|
|
334
|
+
const MARKER_VAULT_ID_RE = /^vault_id:[ \t]*["']?([^"'\r\n#]+)/m;
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The `vault_id` a repo's `heyspex.yaml` carries, or `null` (no marker, no
|
|
338
|
+
* key, unreadable). Never throws. `readFile` is a test seam.
|
|
339
|
+
* @param {string | null | undefined} repoRootAbs
|
|
340
|
+
* @param {{ readFile?: (p: string) => string }} [opts]
|
|
341
|
+
*/
|
|
342
|
+
export function readMarkerVaultId(repoRootAbs, { readFile } = {}) {
|
|
343
|
+
if (typeof repoRootAbs !== 'string' || !repoRootAbs.trim()) return null;
|
|
344
|
+
const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
|
|
345
|
+
try {
|
|
346
|
+
const raw = read(join(repoRootAbs, 'heyspex.yaml'));
|
|
347
|
+
const value = String(raw ?? '').match(MARKER_VAULT_ID_RE)?.[1]?.trim() ?? '';
|
|
348
|
+
return value || null;
|
|
349
|
+
} catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The id to put on the wire for a REGISTERED resolver result: the registry
|
|
356
|
+
* row's id when it is a cloud UUID; otherwise (a legacy slug row such as the
|
|
357
|
+
* pre-UUID `heyspex-specs` registration) the UUID the repo's `heyspex.yaml`
|
|
358
|
+
* `vault_id` names. The committed marker is a first-class routing fact now:
|
|
359
|
+
* the resolver's cwd tier already returns a marker's `vault_id` itself
|
|
360
|
+
* (`cwd-marker`, and `marker-registry-mismatch` when a registry row
|
|
361
|
+
* disagrees — the marker wins, [[HS-VVNQ]]), and its `account:` picks the
|
|
362
|
+
* token ({@link getAuthHeader}, [[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]]);
|
|
363
|
+
* this fallback covers the remaining tiers that land on a slug row. Neither a UUID →
|
|
364
|
+
* throws {@link VaultNotRegisteredError} (`reason: 'legacy-id'`): the console
|
|
365
|
+
* can only scope by the id it minted, and a slug on the wire is a guaranteed
|
|
366
|
+
* 409, so refusing locally is the honest answer.
|
|
367
|
+
* @param {{ vaultId: string, repoRootAbs?: string, source?: string }} resolved
|
|
368
|
+
* @param {{ readMarkerVaultId?: (repoRootAbs: string) => string | null }} [opts]
|
|
369
|
+
*/
|
|
370
|
+
export function cloudVaultIdFor(resolved, { readMarkerVaultId: readMarker = readMarkerVaultId } = {}) {
|
|
371
|
+
const rowId = resolved.vaultId.trim();
|
|
372
|
+
if (isCloudVaultId(rowId)) return rowId;
|
|
373
|
+
const marker = readMarker(resolved.repoRootAbs);
|
|
374
|
+
if (isCloudVaultId(marker)) return marker.trim();
|
|
375
|
+
throw new VaultNotRegisteredError(resolved.repoRootAbs, resolved.source, { reason: 'legacy-id', legacyId: rowId });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Thrown by {@link accountIdHeaderFor} / {@link getAccountIdHeader} when no
|
|
380
|
+
* account resolves (no `HS_ACCOUNT_ID`, no `account_id:` in
|
|
381
|
+
* `~/.heyspex/config.yaml`). Names `hs login`, the only thing that fixes it.
|
|
382
|
+
*/
|
|
383
|
+
export class AccountNotConfiguredError extends Error {
|
|
384
|
+
constructor() {
|
|
385
|
+
super(
|
|
386
|
+
'This machine has no HeySpex account configured (`hs login` has not been run), '
|
|
387
|
+
+ 'so the console call was refused before it was sent. '
|
|
388
|
+
+ `Run \`${hsCommand('login')}\` on this machine, then try again. Nothing was sent.`,
|
|
389
|
+
);
|
|
390
|
+
this.name = 'AccountNotConfiguredError';
|
|
391
|
+
this.code = ACCOUNT_NOT_CONFIGURED;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** True for the two local scope refusals this module throws. */
|
|
396
|
+
export function isLocalScopeRefusal(err) {
|
|
397
|
+
return err instanceof VaultNotRegisteredError || err instanceof AccountNotConfiguredError;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Gate: the vault header for a resolver result. A registered id → the header
|
|
402
|
+
* naming the CLOUD id ({@link cloudVaultIdFor}), whatever `source` says; the
|
|
403
|
+
* `unregistered` sentinel → throws {@link VaultNotRegisteredError}; no result
|
|
404
|
+
* / blank id → `{}` (nothing to name — the resolver itself failed, which is
|
|
405
|
+
* not the same as "unregistered"). Pure given `opts.readMarkerVaultId`.
|
|
406
|
+
* @param {{ vaultId?: string, source?: string, repoRootAbs?: string } | null | undefined} resolved
|
|
407
|
+
* @param {{ readMarkerVaultId?: (repoRootAbs: string) => string | null }} [opts]
|
|
408
|
+
*/
|
|
409
|
+
export function vaultIdHeaderFor(resolved, opts = {}) {
|
|
410
|
+
const vaultId = typeof resolved?.vaultId === 'string' ? resolved.vaultId.trim() : '';
|
|
411
|
+
if (!vaultId) return {};
|
|
412
|
+
if (vaultId === UNREGISTERED_VAULT_ID) {
|
|
413
|
+
throw new VaultNotRegisteredError(resolved?.repoRootAbs, resolved?.source);
|
|
414
|
+
}
|
|
415
|
+
return { [VAULT_ID_HEADER]: cloudVaultIdFor({ ...resolved, vaultId }, opts) };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The vault-id header for THIS process, from the memoized resolver
|
|
420
|
+
* (`getActiveConfig`, resolved once per process; tests reset it via
|
|
421
|
+
* `resetActiveConfigCache`). A resolver FAILURE is "no header" (`{}`); an
|
|
422
|
+
* `unregistered` RESULT throws {@link VaultNotRegisteredError} so the caller
|
|
423
|
+
* refuses before any fetch.
|
|
424
|
+
*/
|
|
425
|
+
export function getVaultIdHeader() {
|
|
426
|
+
let resolved;
|
|
112
427
|
try {
|
|
113
|
-
|
|
114
|
-
const match = raw.match(/^mcp_token:\s*["']?([^"'\r\n#]+)/m);
|
|
115
|
-
const token = match?.[1]?.trim();
|
|
116
|
-
if (token) return { Authorization: `Bearer ${token}` };
|
|
428
|
+
resolved = getActiveConfig();
|
|
117
429
|
} catch {
|
|
118
|
-
|
|
430
|
+
return {};
|
|
119
431
|
}
|
|
120
|
-
return
|
|
432
|
+
return vaultIdHeaderFor(resolved);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Pure gate: the account header for a configured account id, or throws
|
|
437
|
+
* {@link AccountNotConfiguredError} when none is configured.
|
|
438
|
+
* @param {string | null | undefined} accountId
|
|
439
|
+
*/
|
|
440
|
+
export function accountIdHeaderFor(accountId) {
|
|
441
|
+
const id = typeof accountId === 'string' ? accountId.trim() : '';
|
|
442
|
+
if (!id) throw new AccountNotConfiguredError();
|
|
443
|
+
return { [ACCOUNT_ID_HEADER]: id };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The account-id header for THIS process: the repo's recorded `account:`
|
|
448
|
+
* when its heyspex.yaml names one — the account whose token
|
|
449
|
+
* {@link getAuthHeader} sends, so the two headers can never name different
|
|
450
|
+
* accounts ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]]) — else
|
|
451
|
+
* the one account reader (session-owner.mjs: `HS_ACCOUNT_ID` env, then the
|
|
452
|
+
* credentials store's current account). Read per call, like the bearer —
|
|
453
|
+
* `hs login` can re-pair the machine while a server is up. Injectable
|
|
454
|
+
* `env`/`homeDir`/`repoAccountId` for tests (`null` = "the repo records none").
|
|
455
|
+
* @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, repoAccountId?: string | null }} [opts]
|
|
456
|
+
*/
|
|
457
|
+
export function getAccountIdHeader({ repoAccountId, ...opts } = {}) {
|
|
458
|
+
const recorded = repoAccountId === undefined ? getRepoAccountId() : repoAccountId;
|
|
459
|
+
return accountIdHeaderFor(recorded || resolveSessionOwnerUncached(opts));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* The transport result for a local scope refusal — the shape every caller of
|
|
464
|
+
* `consoleFetch` already handles (`ok: false`, `status: 0`), with the code on
|
|
465
|
+
* `refusal` AND on `authProblem` so `consoleErrorCode` / the never-buffer
|
|
466
|
+
* contract pick it up with no per-caller change. Pure.
|
|
467
|
+
* @param {VaultNotRegisteredError | AccountNotConfiguredError} err
|
|
468
|
+
* @param {string} url the request that was NOT sent
|
|
469
|
+
*/
|
|
470
|
+
export function scopeRefusalResult(err, url) {
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
status: 0,
|
|
474
|
+
body: null,
|
|
475
|
+
url,
|
|
476
|
+
error: err.message,
|
|
477
|
+
errorKind: 'refused',
|
|
478
|
+
refusal: err.code,
|
|
479
|
+
authProblem: err.code,
|
|
480
|
+
authMessage: err.message,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Cloud mode, but the resolved console URL points at this machine. */
|
|
485
|
+
export const CONSOLE_LOCAL_URL_IN_CLOUD_MODE = 'CONSOLE_LOCAL_URL_IN_CLOUD_MODE';
|
|
486
|
+
/** Cloud mode, but no bearer token is stored on this machine (or a 401 came back). */
|
|
487
|
+
export const CONSOLE_NOT_SIGNED_IN = 'CONSOLE_NOT_SIGNED_IN';
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Plain-language, action-naming text for the two sign-in classes. Written for
|
|
491
|
+
* the repo owner to read directly: short sentences, no jargon, the exact
|
|
492
|
+
* command to run, and an explicit "do not start a local server" so an agent
|
|
493
|
+
* does not fall back to `npm run dev` — a customer has no local server.
|
|
494
|
+
*
|
|
495
|
+
* The command is rendered by `hsCommand`, never typed out, so the message
|
|
496
|
+
* names a form that actually runs on the machine reading it
|
|
497
|
+
* ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
|
|
498
|
+
*/
|
|
499
|
+
export function consoleAuthErrorMessage(code, url) {
|
|
500
|
+
const where = url ? ` (${url})` : '';
|
|
501
|
+
const login = hsCommand('login');
|
|
502
|
+
if (code === CONSOLE_LOCAL_URL_IN_CLOUD_MODE) {
|
|
503
|
+
return (
|
|
504
|
+
`HeySpex is set to cloud mode, but this machine is pointed at a local console${where}. `
|
|
505
|
+
+ `Nothing was saved. Run \`${login}\` to point this machine at the HeySpex cloud, then try again. `
|
|
506
|
+
+ 'Do not start a local dev server — cloud is the normal mode.'
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
if (code === CONSOLE_NOT_SIGNED_IN) {
|
|
510
|
+
return (
|
|
511
|
+
`This machine is not signed in to HeySpex, so the call${url ? ` to ${url}` : ''} could not go through. `
|
|
512
|
+
+ `Nothing was saved. Run \`${login}\` on this machine, then try again. `
|
|
513
|
+
+ 'The server is not down — do not start a local dev server.'
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Message for an answered HTTP 401 (the console replied; it rejected the token). */
|
|
520
|
+
export function consoleUnauthorizedMessage(url) {
|
|
521
|
+
const where = url ? ` at ${url}` : '';
|
|
522
|
+
return (
|
|
523
|
+
`HeySpex${where} rejected this request because this machine is not signed in (HTTP 401). `
|
|
524
|
+
+ `Nothing was saved. Run \`${hsCommand('login')}\` on this machine, then try again.`
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Lazy, cached, never-throwing load of the shared dev/prod mode core. Kept
|
|
530
|
+
* dynamic (and optional) so a vendored copy of this directory without the
|
|
531
|
+
* repo's `scripts/` tree simply falls back to today's behavior instead of
|
|
532
|
+
* failing to import. Mirrors cli.mjs's handleDevMode self-containment note.
|
|
533
|
+
*/
|
|
534
|
+
let devModeCorePromise;
|
|
535
|
+
async function loadDevModeCore() {
|
|
536
|
+
if (!devModeCorePromise) {
|
|
537
|
+
devModeCorePromise = import('../../scripts/lib/dev-mode.mjs').catch(() => null);
|
|
538
|
+
}
|
|
539
|
+
return devModeCorePromise;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Resolve the effective reliance ("cloud" | "local"), or `null` when it cannot
|
|
544
|
+
* be determined (no dev-mode core available). Never reimplements the contract:
|
|
545
|
+
* `resolveDevMode` (env → machine-local record → default prod/cloud) when the
|
|
546
|
+
* core exports it, else `readLocalDevModeRecord` + `deriveSemantics`.
|
|
547
|
+
*/
|
|
548
|
+
export async function resolveConsoleReliance({ env = process.env, homeDir = homedir() } = {}) {
|
|
549
|
+
const core = await loadDevModeCore();
|
|
550
|
+
if (!core) return null;
|
|
551
|
+
try {
|
|
552
|
+
if (typeof core.resolveDevMode === 'function') {
|
|
553
|
+
return core.resolveDevMode({ env, homeDir }).reliance ?? null;
|
|
554
|
+
}
|
|
555
|
+
const { record } = core.readLocalDevModeRecord({ homeDir });
|
|
556
|
+
const mode = record?.mode ?? 'prod';
|
|
557
|
+
return core.deriveSemantics(mode, record?.reliance ?? null).reliance ?? null;
|
|
558
|
+
} catch {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Decide whether a transport failure is really a sign-in problem. Pure.
|
|
565
|
+
*
|
|
566
|
+
* @param {{ url: string, reliance: string|null, hasToken: boolean }} state
|
|
567
|
+
* @returns {string|null} one of the two auth codes, or null (= genuine outage)
|
|
568
|
+
*/
|
|
569
|
+
export function classifyConsoleAuthProblem({ url, reliance, hasToken }) {
|
|
570
|
+
if (reliance !== 'cloud') return null; // local dev / unknown → today's behavior
|
|
571
|
+
if (isLoopbackUrl(url)) return CONSOLE_LOCAL_URL_IN_CLOUD_MODE;
|
|
572
|
+
if (!hasToken) return CONSOLE_NOT_SIGNED_IN;
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function headerHasToken(header) {
|
|
577
|
+
const value = header?.Authorization;
|
|
578
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
121
579
|
}
|
|
122
580
|
|
|
123
581
|
/**
|
|
@@ -129,6 +587,9 @@ export function getAuthHeader({ env = process.env, homeDir = homedir() } = {}) {
|
|
|
129
587
|
* @param {number} [deps.defaultTimeoutMs]
|
|
130
588
|
* @param {(failedResult: object, ctx: { url: string, init: object, timeoutMs: number }) => Promise<{ retryUrl?: string, reason?: string, healedUrl?: string, discoveryHealed?: boolean, devServerStatus?: object } | null | undefined>} [deps.recoverFromTransportFailure]
|
|
131
589
|
* @param {() => object} [deps.getAuthHeader] Resolves the outbound Authorization header (default: {@link getAuthHeader}).
|
|
590
|
+
* @param {() => object} [deps.getVaultIdHeader] Resolves the outbound `x-heyspex-vault-id` header (default: {@link getVaultIdHeader}); throws {@link VaultNotRegisteredError} for an unregistered root.
|
|
591
|
+
* @param {() => object} [deps.getAccountIdHeader] Resolves the outbound `x-heyspex-account-id` header (default: {@link getAccountIdHeader}); throws {@link AccountNotConfiguredError} when no account is configured.
|
|
592
|
+
* @param {() => Promise<{ reliance: string|null, hasToken: boolean }>} [deps.getConsoleAuthState] Resolves the reliance + token state used to tell "not signed in" from "server down" (default: dev-mode core + {@link getAuthHeader}).
|
|
132
593
|
*/
|
|
133
594
|
export function makeConsoleFetch({
|
|
134
595
|
getConsoleUrl,
|
|
@@ -136,7 +597,72 @@ export function makeConsoleFetch({
|
|
|
136
597
|
defaultTimeoutMs = DEFAULT_CONSOLE_TIMEOUT_MS,
|
|
137
598
|
recoverFromTransportFailure,
|
|
138
599
|
getAuthHeader: getAuthHeaderDep = getAuthHeader,
|
|
600
|
+
getVaultIdHeader: getVaultIdHeaderDep = getVaultIdHeader,
|
|
601
|
+
getAccountIdHeader: getAccountIdHeaderDep = getAccountIdHeader,
|
|
602
|
+
getConsoleAuthState,
|
|
139
603
|
}) {
|
|
604
|
+
function safeAuthHeader() {
|
|
605
|
+
try {
|
|
606
|
+
return getAuthHeaderDep() || {};
|
|
607
|
+
} catch {
|
|
608
|
+
return {};
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* One header resolver, two policies. A resolver that throws a local scope
|
|
614
|
+
* refusal ({@link isLocalScopeRefusal}) is surfaced as `{ refusal }` for a
|
|
615
|
+
* vault-scoped endpoint and swallowed to `{}` for a vault-less one; any
|
|
616
|
+
* OTHER throw is "no header" either way (a broken resolver must not become
|
|
617
|
+
* a new failure mode — unchanged from before).
|
|
618
|
+
*/
|
|
619
|
+
function headerOrRefusal(resolveHeader, vaultLess) {
|
|
620
|
+
try {
|
|
621
|
+
return { headers: resolveHeader() || {} };
|
|
622
|
+
} catch (err) {
|
|
623
|
+
if (!vaultLess && isLocalScopeRefusal(err)) return { refusal: err };
|
|
624
|
+
return { headers: {} };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* The `{ account, vault }` scope headers for one request, or the local
|
|
630
|
+
* refusal that stops it before any fetch. Vault-less endpoints never refuse.
|
|
631
|
+
* @returns {{ headers: object } | { refusal: Error }}
|
|
632
|
+
*/
|
|
633
|
+
function scopeHeadersFor(method, path) {
|
|
634
|
+
const vaultLess = isVaultLessEndpoint(method, path);
|
|
635
|
+
const vault = headerOrRefusal(getVaultIdHeaderDep, vaultLess);
|
|
636
|
+
if (vault.refusal) return vault;
|
|
637
|
+
const account = headerOrRefusal(getAccountIdHeaderDep, vaultLess);
|
|
638
|
+
if (account.refusal) return account;
|
|
639
|
+
return { headers: { ...vault.headers, ...account.headers } };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* The Authorization header for one request, or the local refusal that stops
|
|
644
|
+
* it: a repo that records an account this machine holds no token for
|
|
645
|
+
* ({@link AccountTokenMissingError}) refuses EVERY console call, vault-less
|
|
646
|
+
* ones included — sending no token, or the current account's, would both be
|
|
647
|
+
* wrong. Any other throw is "no header" (unchanged).
|
|
648
|
+
* @returns {{ headers: object } | { refusal: Error }}
|
|
649
|
+
*/
|
|
650
|
+
function authOrRefusal() {
|
|
651
|
+
try {
|
|
652
|
+
return { headers: getAuthHeaderDep() || {} };
|
|
653
|
+
} catch (err) {
|
|
654
|
+
if (err instanceof AccountTokenMissingError) return { refusal: err };
|
|
655
|
+
return { headers: {} };
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const resolveAuthState =
|
|
660
|
+
getConsoleAuthState
|
|
661
|
+
|| (async () => ({
|
|
662
|
+
reliance: await resolveConsoleReliance(),
|
|
663
|
+
hasToken: headerHasToken(safeAuthHeader()),
|
|
664
|
+
}));
|
|
665
|
+
|
|
140
666
|
async function attempt(url, init, timeoutMs) {
|
|
141
667
|
const controller = new AbortController();
|
|
142
668
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -149,7 +675,14 @@ export function makeConsoleFetch({
|
|
|
149
675
|
} catch {
|
|
150
676
|
parsed = { raw: text };
|
|
151
677
|
}
|
|
152
|
-
|
|
678
|
+
const retryAfterRaw = response.headers?.get?.('retry-after');
|
|
679
|
+
return {
|
|
680
|
+
ok: response.ok,
|
|
681
|
+
status: response.status,
|
|
682
|
+
body: parsed,
|
|
683
|
+
url,
|
|
684
|
+
...(retryAfterRaw != null && retryAfterRaw !== '' ? { retryAfterSeconds: retryAfterRaw } : {}),
|
|
685
|
+
};
|
|
153
686
|
} catch (err) {
|
|
154
687
|
const errorKind = isAbortError(err) ? 'timeout' : 'network';
|
|
155
688
|
return {
|
|
@@ -244,15 +777,52 @@ export function makeConsoleFetch({
|
|
|
244
777
|
return result;
|
|
245
778
|
}
|
|
246
779
|
|
|
780
|
+
/**
|
|
781
|
+
* Last word on a status-0 failure: was the console really down, or were we
|
|
782
|
+
* talking to the wrong console / with no sign-in? Runs only AFTER every
|
|
783
|
+
* recovery path has failed, so a genuinely recoverable local dev server
|
|
784
|
+
* still heals exactly as before ([[HS-9R6S-N8VP-DIAG]]).
|
|
785
|
+
*/
|
|
786
|
+
async function stampAuthProblem(result, url) {
|
|
787
|
+
let state;
|
|
788
|
+
try {
|
|
789
|
+
state = await resolveAuthState({ url });
|
|
790
|
+
} catch {
|
|
791
|
+
return result; // never let classification turn into a new failure mode
|
|
792
|
+
}
|
|
793
|
+
const authProblem = classifyConsoleAuthProblem({
|
|
794
|
+
url: result.url || url,
|
|
795
|
+
reliance: state?.reliance ?? null,
|
|
796
|
+
hasToken: state?.hasToken === true,
|
|
797
|
+
});
|
|
798
|
+
if (!authProblem) return result;
|
|
799
|
+
return {
|
|
800
|
+
...result,
|
|
801
|
+
authProblem,
|
|
802
|
+
reliance: state?.reliance ?? null,
|
|
803
|
+
authMessage: consoleAuthErrorMessage(authProblem, result.url || url),
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
247
807
|
async function withRetry(url, init, timeoutMs, { retryOnTimeout = true } = {}) {
|
|
248
808
|
let result = await attempt(url, init, timeoutMs);
|
|
249
809
|
if (!result.ok && result.errorKind === 'timeout' && retryOnTimeout) {
|
|
250
810
|
result = await attempt(url, init, timeoutMs);
|
|
251
811
|
}
|
|
812
|
+
// Cold cloud lambda: wait the advertised window, then the same URL once more.
|
|
813
|
+
if (!result.ok && isVaultScopeUnresolved(result)) {
|
|
814
|
+
await sleep(retryAfterMs(result));
|
|
815
|
+
const retry = await attempt(url, init, timeoutMs);
|
|
816
|
+
if (retry.ok) return { ...retry, recoveredVia: 'vault_scope_unresolved', attemptedUrl: url };
|
|
817
|
+
result = retry;
|
|
818
|
+
}
|
|
252
819
|
if (result.ok) return result;
|
|
253
820
|
if (result.errorKind === 'network') {
|
|
254
821
|
result = await recoverNetworkFailure(result, { url, init, timeoutMs });
|
|
255
822
|
}
|
|
823
|
+
if (!result.ok && result.status === 0) {
|
|
824
|
+
result = await stampAuthProblem(result, url);
|
|
825
|
+
}
|
|
256
826
|
return result;
|
|
257
827
|
}
|
|
258
828
|
|
|
@@ -261,10 +831,15 @@ export function makeConsoleFetch({
|
|
|
261
831
|
*/
|
|
262
832
|
async function postClaimEndpoint(method, specId, bodyJson) {
|
|
263
833
|
const consoleUrl = getConsoleUrl();
|
|
264
|
-
const
|
|
834
|
+
const path = `/api/specs/${encodeURIComponent(specId)}/claim`;
|
|
835
|
+
const url = `${consoleUrl}${path}`;
|
|
836
|
+
const auth = authOrRefusal();
|
|
837
|
+
if (auth.refusal) return scopeRefusalResult(auth.refusal, url);
|
|
838
|
+
const scope = scopeHeadersFor(method, path);
|
|
839
|
+
if (scope.refusal) return scopeRefusalResult(scope.refusal, url);
|
|
265
840
|
const init = {
|
|
266
841
|
method,
|
|
267
|
-
headers: { 'content-type': 'application/json', ...
|
|
842
|
+
headers: { 'content-type': 'application/json', ...auth.headers, ...scope.headers },
|
|
268
843
|
};
|
|
269
844
|
if (bodyJson !== undefined) {
|
|
270
845
|
init.body = JSON.stringify(bodyJson);
|
|
@@ -287,9 +862,19 @@ export function makeConsoleFetch({
|
|
|
287
862
|
async function consoleFetch(path, options = {}) {
|
|
288
863
|
const consoleUrl = getConsoleUrl();
|
|
289
864
|
const url = `${consoleUrl}${path}`;
|
|
865
|
+
const method = options.method || 'GET';
|
|
866
|
+
const auth = authOrRefusal();
|
|
867
|
+
if (auth.refusal) return scopeRefusalResult(auth.refusal, url);
|
|
868
|
+
const scope = scopeHeadersFor(method, path);
|
|
869
|
+
if (scope.refusal) return scopeRefusalResult(scope.refusal, url);
|
|
290
870
|
const init = {
|
|
291
|
-
method
|
|
292
|
-
headers: {
|
|
871
|
+
method,
|
|
872
|
+
headers: {
|
|
873
|
+
'content-type': 'application/json',
|
|
874
|
+
...auth.headers,
|
|
875
|
+
...scope.headers,
|
|
876
|
+
...options.headers,
|
|
877
|
+
},
|
|
293
878
|
};
|
|
294
879
|
if (options.body !== undefined) {
|
|
295
880
|
init.body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body);
|
|
@@ -308,7 +893,51 @@ export function makeConsoleFetch({
|
|
|
308
893
|
* actually down). Caller supplies the HTTP-error fallback used when the
|
|
309
894
|
* transport succeeded but the console returned a 4xx/5xx.
|
|
310
895
|
*/
|
|
896
|
+
/**
|
|
897
|
+
* The console's three TYPED vault-scope refusals
|
|
898
|
+
* ([[HS-T5J5-WW9R-TASK-typed-vault-refusals-vault-not]]), one envelope
|
|
899
|
+
* `{ error, reason, requested, hint }`. They name WHICH vault was refused and
|
|
900
|
+
* why; the `hint` names the account's active vault when it is known. The
|
|
901
|
+
* transport renders them by name so an agent reads two vault ids, not a bare
|
|
902
|
+
* status ([[HS-N0VS-WW9R-TASK-hs-doctor-and-the-mcp-transport-name]] for
|
|
903
|
+
* [[HS-W3JF-PEGM-DIAG-one-account-wide-active-vault-pointer]] fix 3).
|
|
904
|
+
*/
|
|
905
|
+
export const SCOPE_REFUSAL_CODES = Object.freeze(['VAULT_SCOPE_MISMATCH', 'ACCOUNT_MISMATCH', 'VAULT_NOT_REGISTERED']);
|
|
906
|
+
|
|
907
|
+
/** The typed refusal envelope on a failed transport result, or `null`. Pure. */
|
|
908
|
+
export function scopeRefusalEnvelope(result) {
|
|
909
|
+
const body = result?.body;
|
|
910
|
+
if (!body || typeof body !== 'object' || !SCOPE_REFUSAL_CODES.includes(body.error)) return null;
|
|
911
|
+
return body;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Agent-facing text for a typed refusal: code, reason, the vault the request
|
|
916
|
+
* named, and the console's hint (which carries the account's active vault when
|
|
917
|
+
* known) — the "both vaults side by side" the incident lacked. Pure.
|
|
918
|
+
*/
|
|
919
|
+
export function scopeRefusalMessage(envelope, url) {
|
|
920
|
+
const where = url ? ` from ${url}` : '';
|
|
921
|
+
const reason = typeof envelope.reason === 'string' && envelope.reason ? envelope.reason : 'no reason given';
|
|
922
|
+
const requested =
|
|
923
|
+
typeof envelope.requested === 'string' && envelope.requested
|
|
924
|
+
? `vault ${envelope.requested}`
|
|
925
|
+
: `no vault (no \`${VAULT_ID_HEADER}\` header)`;
|
|
926
|
+
const hint = typeof envelope.hint === 'string' && envelope.hint ? ` ${envelope.hint}` : '';
|
|
927
|
+
return `${envelope.error} (${reason})${where}: this request named ${requested}.${hint}`.trim();
|
|
928
|
+
}
|
|
929
|
+
|
|
311
930
|
export function consoleErrorCode(result, httpFailureCode) {
|
|
931
|
+
// A local scope refusal never reached the wire: the code IS the answer.
|
|
932
|
+
if (result.refusal) return result.refusal;
|
|
933
|
+
// A sign-in problem outranks every transport story: no amount of retrying,
|
|
934
|
+
// port discovery or waiting fixes it, and "the server is down" sends the
|
|
935
|
+
// agent off to start a local dev server the customer does not have.
|
|
936
|
+
if (result.authProblem) return result.authProblem;
|
|
937
|
+
// A typed vault-scope refusal IS the code — never the caller's generic one.
|
|
938
|
+
const scoped = scopeRefusalEnvelope(result);
|
|
939
|
+
if (scoped) return scoped.error;
|
|
940
|
+
if (result.status === 401) return CONSOLE_NOT_SIGNED_IN;
|
|
312
941
|
if (result.urlMismatch) return 'CONSOLE_URL_MISMATCH';
|
|
313
942
|
if (result.errorKind === 'timeout') return 'CONSOLE_TIMEOUT';
|
|
314
943
|
if (result.status === 0) return 'CONSOLE_UNREACHABLE';
|
|
@@ -321,6 +950,18 @@ export function consoleErrorCode(result, httpFailureCode) {
|
|
|
321
950
|
* server on network error.
|
|
322
951
|
*/
|
|
323
952
|
export function consoleErrorMessage(result, httpFailureMessage) {
|
|
953
|
+
if (result.refusal) return result.error || result.authMessage || httpFailureMessage;
|
|
954
|
+
if (result.authProblem) {
|
|
955
|
+
return (
|
|
956
|
+
result.authMessage
|
|
957
|
+
|| consoleAuthErrorMessage(result.authProblem, result.url)
|
|
958
|
+
|| httpFailureMessage
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
// A typed vault-scope refusal names both vaults; the generic text names none.
|
|
962
|
+
const scoped = scopeRefusalEnvelope(result);
|
|
963
|
+
if (scoped) return scopeRefusalMessage(scoped, result.url);
|
|
964
|
+
if (result.status === 401) return consoleUnauthorizedMessage(result.url);
|
|
324
965
|
if (result.urlMismatch) {
|
|
325
966
|
const live =
|
|
326
967
|
result.suggestedUrl
|