@cohortapp/agent-sdk 2.16.0 → 2.18.4
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/.claude/settings.json +18 -0
- package/.env.example +23 -7
- package/README.md +1 -0
- package/bin/maestro.mjs +62 -0
- package/docs/guides/billing-console-keys.md +60 -0
- package/docs/guides/front-door-session.md +54 -9
- package/docs/guides/mac-mini.md +20 -25
- package/docs/guides/poller-daemon-setup.md +4 -1
- package/docs/guides/setup-wizard.md +1 -1
- package/docs/runbooks/fleet-rollout.md +156 -0
- package/docs/runbooks/mac-mini-bootstrap.md +12 -14
- package/lib/action-executor.js +19 -3
- package/lib/budget-guard.mjs +279 -3
- package/lib/channels/base-adapter.mjs +3 -1
- package/lib/channels/contract.mjs +2 -1
- package/lib/channels/inbox-item.mjs +8 -0
- package/lib/claude-bin.mjs +5 -6
- package/lib/cli/doctor-checks.mjs +141 -10
- package/lib/cli/global-setup-extras.mjs +5 -1
- package/lib/cli/inbox.mjs +100 -15
- package/lib/cli/seat-auth.mjs +463 -0
- package/lib/cli/session.mjs +80 -12
- package/lib/collective/capture.mjs +8 -6
- package/lib/collective/global-config.mjs +63 -1
- package/lib/collective/presence.mjs +142 -5
- package/lib/comms/send-gate.mjs +559 -1
- package/lib/context/budget.mjs +327 -0
- package/lib/context/history-scope.mjs +138 -0
- package/lib/diagnostics/alerts.mjs +49 -0
- package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
- package/lib/engine/agents/definitions.mjs +343 -0
- package/lib/engine/agents/persist.mjs +275 -0
- package/lib/engine/agents/runtime.mjs +748 -0
- package/lib/engine/agents/usage.mjs +95 -0
- package/lib/engine/auth-status.mjs +139 -0
- package/lib/engine/budget.mjs +194 -0
- package/lib/engine/cli.mjs +1204 -0
- package/lib/engine/commands/index.mjs +269 -0
- package/lib/engine/context/budget.mjs +219 -0
- package/lib/engine/context/cache.mjs +125 -0
- package/lib/engine/context/child-env.mjs +215 -0
- package/lib/engine/context/compaction.mjs +342 -0
- package/lib/engine/context/images.mjs +90 -0
- package/lib/engine/context/instructions.mjs +327 -0
- package/lib/engine/context/lazy-instructions.mjs +169 -0
- package/lib/engine/context/manager.mjs +182 -0
- package/lib/engine/context/real-path.mjs +91 -0
- package/lib/engine/context/secret-values.mjs +163 -0
- package/lib/engine/context/settings.mjs +274 -0
- package/lib/engine/context/stream-input.mjs +159 -0
- package/lib/engine/guard.mjs +152 -0
- package/lib/engine/hooks.mjs +713 -0
- package/lib/engine/loop.mjs +560 -0
- package/lib/engine/mcp/client.mjs +254 -0
- package/lib/engine/mcp/config.mjs +301 -0
- package/lib/engine/mcp/http.mjs +201 -0
- package/lib/engine/mcp/index.mjs +146 -0
- package/lib/engine/mcp/jsonrpc.mjs +147 -0
- package/lib/engine/mcp/naming.mjs +66 -0
- package/lib/engine/mcp/resources.mjs +89 -0
- package/lib/engine/mcp/results.mjs +133 -0
- package/lib/engine/mcp/stdio.mjs +137 -0
- package/lib/engine/mcp/supervisor.mjs +116 -0
- package/lib/engine/messages.mjs +104 -0
- package/lib/engine/output/json.mjs +164 -0
- package/lib/engine/output/stream-json.mjs +266 -0
- package/lib/engine/permissions.mjs +845 -0
- package/lib/engine/process-identity.mjs +164 -0
- package/lib/engine/process-tree.mjs +551 -0
- package/lib/engine/prompt.mjs +60 -0
- package/lib/engine/session/store.mjs +299 -0
- package/lib/engine/session-runtime/args.mjs +97 -0
- package/lib/engine/session-runtime/host.mjs +143 -0
- package/lib/engine/session-runtime/inbox.mjs +122 -0
- package/lib/engine/session-runtime/notifications.mjs +129 -0
- package/lib/engine/session-runtime/registry.mjs +328 -0
- package/lib/engine/session-runtime/runner.mjs +344 -0
- package/lib/engine/session-runtime/socket.mjs +212 -0
- package/lib/engine/session-runtime/wakeup.mjs +115 -0
- package/lib/engine/skills/index.mjs +321 -0
- package/lib/engine/tools/bash-background.mjs +533 -0
- package/lib/engine/tools/bash.mjs +216 -0
- package/lib/engine/tools/edit.mjs +97 -0
- package/lib/engine/tools/glob.mjs +81 -0
- package/lib/engine/tools/grep.mjs +224 -0
- package/lib/engine/tools/index.mjs +84 -0
- package/lib/engine/tools/list-agents.mjs +32 -0
- package/lib/engine/tools/ls.mjs +127 -0
- package/lib/engine/tools/monitor.mjs +82 -0
- package/lib/engine/tools/notebook-edit.mjs +218 -0
- package/lib/engine/tools/read.mjs +103 -0
- package/lib/engine/tools/schedule-wakeup.mjs +45 -0
- package/lib/engine/tools/schema.mjs +144 -0
- package/lib/engine/tools/send-message.mjs +77 -0
- package/lib/engine/tools/session.mjs +70 -0
- package/lib/engine/tools/todo.mjs +144 -0
- package/lib/engine/tools/toolsearch.mjs +217 -0
- package/lib/engine/tools/walk.mjs +193 -0
- package/lib/engine/tools/web-switch.mjs +31 -0
- package/lib/engine/tools/webfetch-html.mjs +387 -0
- package/lib/engine/tools/webfetch-net.mjs +340 -0
- package/lib/engine/tools/webfetch.mjs +198 -0
- package/lib/engine/tools/websearch.mjs +91 -0
- package/lib/engine/tools/workflow.mjs +95 -0
- package/lib/engine/tools/write.mjs +76 -0
- package/lib/engine/tui/line-editor.mjs +137 -0
- package/lib/engine/tui/render.mjs +86 -0
- package/lib/engine/tui/tui.mjs +274 -0
- package/lib/engine/wire/anthropic-messages.mjs +263 -0
- package/lib/engine/wire/effort.mjs +36 -0
- package/lib/engine/wire/errors.mjs +496 -0
- package/lib/engine/wire/http.mjs +441 -0
- package/lib/engine/wire/index.mjs +76 -0
- package/lib/engine/wire/openai-chat.mjs +332 -0
- package/lib/engine/wire/prompt-cache.mjs +79 -0
- package/lib/engine/wire/search.mjs +140 -0
- package/lib/engine/wire/sse.mjs +114 -0
- package/lib/engine/wire/stall.mjs +349 -0
- package/lib/engine/wire/token-provider.mjs +175 -0
- package/lib/engine/wire/usage.mjs +192 -0
- package/lib/engine/workflow/host.mjs +524 -0
- package/lib/engine/workflow/journal.mjs +188 -0
- package/lib/engine/workflow/json-schema.mjs +171 -0
- package/lib/engine/workflow/meta.mjs +329 -0
- package/lib/engine/workflow/notifications.mjs +52 -0
- package/lib/engine/workflow/runtime.mjs +447 -0
- package/lib/engine/workflow/sandbox.mjs +534 -0
- package/lib/engine/workflow/worker.mjs +141 -0
- package/lib/engine/workflow/worktree.mjs +74 -0
- package/lib/execution/disposition.mjs +1 -1
- package/lib/execution/intake.mjs +10 -0
- package/lib/execution/surface-policy.mjs +15 -0
- package/lib/learning/curator.mjs +8 -6
- package/lib/learning/reflect.mjs +8 -6
- package/lib/model-router/catalog/cohort.yaml +137 -0
- package/lib/model-router/catalog.mjs +118 -1
- package/lib/model-router/economics.mjs +9 -0
- package/lib/model-router/failover.mjs +67 -16
- package/lib/model-router/llm-task.mjs +39 -3
- package/lib/model-router/resolve.mjs +95 -3
- package/lib/model-router/spawn.mjs +46 -47
- package/lib/model-router/taxonomy.mjs +126 -4
- package/lib/org/cost-sync.mjs +141 -11
- package/lib/org/inbound/broadcast.mjs +289 -0
- package/lib/org/inbound/collective.mjs +375 -0
- package/lib/org/inbound/directedness.mjs +96 -8
- package/lib/org/inbound/facts.mjs +82 -4
- package/lib/org/inbound/hydrate.mjs +555 -51
- package/lib/org/inbound/project.mjs +22 -0
- package/lib/org/inbound/surfaces.mjs +14 -0
- package/lib/org/llm-token.mjs +879 -0
- package/lib/org/mesh.mjs +61 -0
- package/lib/org/messaging.mjs +3 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +15 -0
- package/lib/org/quota.mjs +520 -0
- package/lib/org/tool-surface.mjs +104 -16
- package/lib/org/ui-parity.mjs +16 -1
- package/lib/org/work-ledger.mjs +37 -6
- package/lib/rate-guard.mjs +114 -1
- package/lib/resource-governor.mjs +41 -6
- package/lib/runtime/adapter.mjs +823 -0
- package/lib/runtime/child-env.mjs +191 -0
- package/lib/runtime/legacy-shell-guard.mjs +97 -0
- package/lib/runtime/seat-engine.mjs +162 -0
- package/lib/session/ask-ledger.mjs +271 -0
- package/lib/session/current-work.mjs +676 -0
- package/lib/session/feed-core.mjs +40 -3
- package/lib/session/launch-args.mjs +56 -4
- package/lib/session/status-summary.mjs +26 -9
- package/lib/session/upgrade-notice.mjs +42 -0
- package/lib/setup/claude-probe.mjs +117 -13
- package/lib/setup/enrich.mjs +13 -10
- package/lib/setup/sections/model.mjs +39 -13
- package/lib/telemetry/collect.mjs +208 -9
- package/lib/upgrade/ignored-drift.mjs +105 -0
- package/lib/voice/post-call-brief.mjs +30 -17
- package/package.json +15 -3
- package/plugins/maestro-skills/skills/board-work.md +5 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
- package/plugins/maestro-skills/skills/main-session.md +18 -7
- package/scripts/ci/check-tarball-fidelity.mjs +126 -2
- package/scripts/ci/run-tests.mjs +47 -19
- package/scripts/cohort-llm/api-key-helper.mjs +92 -0
- package/scripts/collective/hook-runner.mjs +29 -2
- package/scripts/continuous-monitor.sh +13 -0
- package/scripts/cost/track-claude-usage.mjs +15 -0
- package/scripts/daemon/agent-daemon.mjs +408 -20
- package/scripts/daemon/assurance.mjs +48 -12
- package/scripts/daemon/cadence-consumer.mjs +218 -68
- package/scripts/daemon/cadence-handlers.mjs +73 -4
- package/scripts/daemon/classifier.mjs +75 -26
- package/scripts/daemon/context-compiler.mjs +104 -59
- package/scripts/daemon/deliver.mjs +30 -1
- package/scripts/daemon/dispatcher.mjs +804 -157
- package/scripts/daemon/health.mjs +14 -1
- package/scripts/daemon/lib/session-router.mjs +310 -42
- package/scripts/daemon/maestro-daemon.mjs +11 -0
- package/scripts/daemon/prompt-builder.mjs +121 -12
- package/scripts/daemon/responder.mjs +315 -146
- package/scripts/daemon/sdk-version.mjs +98 -16
- package/scripts/eval/probe-gateway.mjs +635 -0
- package/scripts/eval/replay/extract.mjs +270 -0
- package/scripts/eval/replay/grade.mjs +260 -0
- package/scripts/eval/replay/lib/config.mjs +50 -0
- package/scripts/eval/replay/lib/effects.mjs +65 -0
- package/scripts/eval/replay/lib/fixture.mjs +188 -0
- package/scripts/eval/replay/lib/judge.mjs +72 -0
- package/scripts/eval/replay/lib/redact.mjs +136 -0
- package/scripts/eval/replay/lib/sandbox.mjs +170 -0
- package/scripts/eval/replay/lib/schema-check.mjs +63 -0
- package/scripts/eval/replay/lib/transcript.mjs +76 -0
- package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
- package/scripts/eval/replay/report.mjs +185 -0
- package/scripts/eval/replay/run.mjs +404 -0
- package/scripts/fleet/rollout.mjs +1094 -0
- package/scripts/hooks/pre-send-audit.sh +36 -245
- package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
- package/scripts/hooks/validate-state-yaml.sh +190 -0
- package/scripts/huddle/huddle-llm.mjs +361 -0
- package/scripts/huddle/huddle-server.mjs +46 -121
- package/scripts/local-triggers/autoupdate.sh +448 -78
- package/scripts/local-triggers/run-trigger.sh +13 -0
- package/scripts/maintenance/pin-integrity.mjs +364 -0
- package/scripts/poll-slack-events.sh +41 -9
- package/scripts/poller/slack-socket-mode.mjs +28 -3
- package/scripts/session/supervisor.mjs +80 -13
- package/scripts/spawn-session.sh +13 -0
- package/bin/maestro.test.mjs +0 -1574
- package/lib/action-executor.test.mjs +0 -871
- package/lib/archetype.test.mjs +0 -132
- package/lib/assurance/plan-note.test.mjs +0 -234
- package/lib/assurance/room-budget.test.mjs +0 -486
- package/lib/assurance/tier.test.mjs +0 -174
- package/lib/autonomy.test.mjs +0 -66
- package/lib/backlog.test.mjs +0 -302
- package/lib/backup/policy.test.mjs +0 -305
- package/lib/budget-escalate.test.mjs +0 -232
- package/lib/budget-guard.envelope.test.mjs +0 -476
- package/lib/budget-guard.test.mjs +0 -427
- package/lib/cadence-bus-requeue.test.mjs +0 -83
- package/lib/cadence-bus-schedule.test.mjs +0 -194
- package/lib/cadence-bus.test.mjs +0 -720
- package/lib/cadences.test.mjs +0 -230
- package/lib/capability/inventory.test.mjs +0 -232
- package/lib/capability.test.mjs +0 -78
- package/lib/channels/base-adapter.test.mjs +0 -590
- package/lib/channels/channels.test.mjs +0 -371
- package/lib/channels/contract.test.mjs +0 -162
- package/lib/channels/inbox-item.test.mjs +0 -368
- package/lib/channels/orgmail/adapter.test.mjs +0 -448
- package/lib/channels/pairing.test.mjs +0 -270
- package/lib/channels/repeat-suppressor.test.mjs +0 -134
- package/lib/channels/slack-adapter.test.mjs +0 -212
- package/lib/channels/telegram-adapter.test.mjs +0 -306
- package/lib/channels/voice/adapter.test.mjs +0 -278
- package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
- package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
- package/lib/charter.test.mjs +0 -89
- package/lib/claude-bin.test.mjs +0 -131
- package/lib/cli/board.test.mjs +0 -227
- package/lib/cli/design.test.mjs +0 -270
- package/lib/cli/doctor-checks.test.mjs +0 -336
- package/lib/cli/global-setup-extras.test.mjs +0 -462
- package/lib/cli/inbox.test.mjs +0 -230
- package/lib/cli/session-ack.test.mjs +0 -63
- package/lib/cli/session.test.mjs +0 -613
- package/lib/collective/capture.test.mjs +0 -121
- package/lib/collective/cards.test.mjs +0 -114
- package/lib/collective/config.test.mjs +0 -123
- package/lib/collective/global-config.test.mjs +0 -220
- package/lib/collective/global-skills.test.mjs +0 -126
- package/lib/collective/presence.test.mjs +0 -95
- package/lib/collective/recall.test.mjs +0 -116
- package/lib/collective/vendor-skills.test.mjs +0 -306
- package/lib/comms/send-gate.test.mjs +0 -770
- package/lib/comms.test.mjs +0 -41
- package/lib/cost/ledger-row.test.mjs +0 -183
- package/lib/design/design-md.test.mjs +0 -318
- package/lib/design/fixtures/DESIGN.golden.md +0 -238
- package/lib/design/fixtures/PRODUCT.golden.md +0 -67
- package/lib/design/fixtures/foundation.json +0 -133
- package/lib/design/refresh-gate.test.mjs +0 -144
- package/lib/design/write.test.mjs +0 -241
- package/lib/diagnostics/alerts.test.mjs +0 -318
- package/lib/diagnostics/backup-freshness.test.mjs +0 -185
- package/lib/diagnostics/counters.test.mjs +0 -206
- package/lib/diagnostics/events.test.mjs +0 -290
- package/lib/diagnostics/otel.test.mjs +0 -196
- package/lib/diagnostics/trace.test.mjs +0 -251
- package/lib/env-compat.test.mjs +0 -104
- package/lib/execution/disposition.test.mjs +0 -553
- package/lib/execution/drive.test.mjs +0 -270
- package/lib/execution/effects.test.mjs +0 -344
- package/lib/execution/intake.test.mjs +0 -389
- package/lib/execution/journal.test.mjs +0 -261
- package/lib/execution/match.test.mjs +0 -235
- package/lib/execution/pipeline.test.mjs +0 -392
- package/lib/execution/route.test.mjs +0 -186
- package/lib/execution/surface-policy.test.mjs +0 -162
- package/lib/fs-atomic.test.mjs +0 -72
- package/lib/fs-ownership.test.mjs +0 -158
- package/lib/goals/admission.test.mjs +0 -164
- package/lib/goals/classify.test.mjs +0 -167
- package/lib/goals/collaborate.test.mjs +0 -336
- package/lib/goals/gaps.test.mjs +0 -284
- package/lib/goals/loop.test.mjs +0 -845
- package/lib/hooks/bus.test.mjs +0 -387
- package/lib/identity/persona.test.mjs +0 -142
- package/lib/kpi-sensors.test.mjs +0 -278
- package/lib/kpi.test.mjs +0 -244
- package/lib/learning/config.test.mjs +0 -75
- package/lib/learning/counters.test.mjs +0 -69
- package/lib/learning/curator-consolidate.test.mjs +0 -238
- package/lib/learning/curator.test.mjs +0 -106
- package/lib/learning/reflect.test.mjs +0 -0
- package/lib/learning/session-index.test.mjs +0 -125
- package/lib/learning/skill-writer.test.mjs +0 -210
- package/lib/mandate/audit.test.mjs +0 -195
- package/lib/mandate/contract.test.mjs +0 -185
- package/lib/mandate/derive.test.mjs +0 -274
- package/lib/mandate/model.test.mjs +0 -164
- package/lib/mandate/refresh.test.mjs +0 -389
- package/lib/mcp/server.test.mjs +0 -426
- package/lib/model-router/auth-profiles.test.mjs +0 -580
- package/lib/model-router/catalog.test.mjs +0 -385
- package/lib/model-router/economics.test.mjs +0 -438
- package/lib/model-router/failover.test.mjs +0 -439
- package/lib/model-router/health.test.mjs +0 -338
- package/lib/model-router/integration-coverage.test.mjs +0 -831
- package/lib/model-router/integration.test.mjs +0 -564
- package/lib/model-router/ledger.test.mjs +0 -415
- package/lib/model-router/llm-task.test.mjs +0 -392
- package/lib/model-router/org-credentials.test.mjs +0 -265
- package/lib/model-router/pricing-refresh.test.mjs +0 -286
- package/lib/model-router/reconcile.test.mjs +0 -316
- package/lib/model-router/repair.test.mjs +0 -180
- package/lib/model-router/spawn.test.mjs +0 -446
- package/lib/model-router/taxonomy.test.mjs +0 -410
- package/lib/model-router.test.mjs +0 -1207
- package/lib/org/activity.test.mjs +0 -134
- package/lib/org/approvals.test.mjs +0 -216
- package/lib/org/awareness.test.mjs +0 -159
- package/lib/org/board-mine-cache.test.mjs +0 -53
- package/lib/org/board.test.mjs +0 -187
- package/lib/org/bootstrap-context.test.mjs +0 -153
- package/lib/org/client.test.mjs +0 -1206
- package/lib/org/cohort-client.test.mjs +0 -126
- package/lib/org/cost-sync.test.mjs +0 -153
- package/lib/org/doctor.test.mjs +0 -346
- package/lib/org/engagement-ledger.test.mjs +0 -112
- package/lib/org/engagement.test.mjs +0 -739
- package/lib/org/handoff.test.mjs +0 -269
- package/lib/org/inbound/directedness.test.mjs +0 -668
- package/lib/org/inbound/facts.test.mjs +0 -471
- package/lib/org/inbound/hydrate.test.mjs +0 -453
- package/lib/org/inbound/index.test.mjs +0 -429
- package/lib/org/inbound/project.test.mjs +0 -287
- package/lib/org/integration-tools.test.mjs +0 -160
- package/lib/org/keys.test.mjs +0 -92
- package/lib/org/knowledge.test.mjs +0 -326
- package/lib/org/leases.test.mjs +0 -235
- package/lib/org/mesh-directives.test.mjs +0 -110
- package/lib/org/mesh-integration.test.mjs +0 -127
- package/lib/org/mesh.test.mjs +0 -400
- package/lib/org/messaging.test.mjs +0 -471
- package/lib/org/param-contract.test.mjs +0 -477
- package/lib/org/policy.test.mjs +0 -237
- package/lib/org/protocol.checksum.test.mjs +0 -90
- package/lib/org/protocol.test.mjs +0 -323
- package/lib/org/push.test.mjs +0 -792
- package/lib/org/registry.test.mjs +0 -100
- package/lib/org/resource-tools.test.mjs +0 -361
- package/lib/org/tool-access.test.mjs +0 -144
- package/lib/org/tool-surface-integration.test.mjs +0 -120
- package/lib/org/tool-surface.test.mjs +0 -1268
- package/lib/org/typing.test.mjs +0 -291
- package/lib/org/ui-parity.test.mjs +0 -560
- package/lib/org/verify.test.mjs +0 -194
- package/lib/org/work-ledger.test.mjs +0 -273
- package/lib/plan/adoption-e2e.test.mjs +0 -366
- package/lib/plan/budget-enforcement.test.mjs +0 -400
- package/lib/plan/compile.test.mjs +0 -382
- package/lib/plan/emit.test.mjs +0 -269
- package/lib/plan/explain.test.mjs +0 -188
- package/lib/prompts/parallelism.test.mjs +0 -177
- package/lib/rag/rag.test.mjs +0 -505
- package/lib/rate-guard.test.mjs +0 -272
- package/lib/reactive-gate.test.mjs +0 -57
- package/lib/render.test.mjs +0 -68
- package/lib/resource-governor.test.mjs +0 -488
- package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
- package/lib/scheduling/jitter.test.mjs +0 -140
- package/lib/secrets/broker.test.mjs +0 -280
- package/lib/secrets/providers.test.mjs +0 -274
- package/lib/security/audit-engine.test.mjs +0 -424
- package/lib/security/coerce-args.test.mjs +0 -281
- package/lib/security/dangerous-tools.test.mjs +0 -68
- package/lib/security/external-content.test.mjs +0 -84
- package/lib/security/redact.test.mjs +0 -441
- package/lib/security/secret-equal.test.mjs +0 -55
- package/lib/session/config.test.mjs +0 -92
- package/lib/session/feed-core.test.mjs +0 -198
- package/lib/session/first-run.test.mjs +0 -121
- package/lib/session/frontdoor.test.mjs +0 -205
- package/lib/session/handoffs.test.mjs +0 -183
- package/lib/session/identity.test.mjs +0 -180
- package/lib/session/inbox-claims.test.mjs +0 -286
- package/lib/session/launch-args.test.mjs +0 -157
- package/lib/session/liveness.test.mjs +0 -100
- package/lib/session/status-summary.test.mjs +0 -118
- package/lib/session-permissions.test.mjs +0 -120
- package/lib/setup/claude-probe.test.mjs +0 -187
- package/lib/setup/completeness.test.mjs +0 -110
- package/lib/setup/context-pack.test.mjs +0 -89
- package/lib/setup/enrich.test.mjs +0 -115
- package/lib/setup/enroll-from-cohort.test.mjs +0 -300
- package/lib/setup/integration.test.mjs +0 -162
- package/lib/setup/io.test.mjs +0 -77
- package/lib/setup/runner.test.mjs +0 -132
- package/lib/setup/sections/identity.test.mjs +0 -234
- package/lib/setup/sections/inventory.test.mjs +0 -198
- package/lib/setup/sections/learning.test.mjs +0 -81
- package/lib/setup/sections/mandate.test.mjs +0 -388
- package/lib/setup/sections/messaging.test.mjs +0 -127
- package/lib/setup/sections/model.test.mjs +0 -240
- package/lib/setup/sections/org.test.mjs +0 -346
- package/lib/setup/sections/orgmail.test.mjs +0 -118
- package/lib/setup/sections/recovery.test.mjs +0 -98
- package/lib/setup/sections/subagents.test.mjs +0 -429
- package/lib/setup/sections/verify.test.mjs +0 -175
- package/lib/setup/sot.test.mjs +0 -81
- package/lib/setup/state.test.mjs +0 -115
- package/lib/singleton.test.mjs +0 -151
- package/lib/subagents/cli.test.mjs +0 -389
- package/lib/subagents/client.test.mjs +0 -309
- package/lib/subagents/gap.test.mjs +0 -234
- package/lib/subagents/lock.test.mjs +0 -248
- package/lib/subagents/manifest.test.mjs +0 -175
- package/lib/subagents/refs.test.mjs +0 -204
- package/lib/subagents/resolve.test.mjs +0 -422
- package/lib/subagents/schema.test.mjs +0 -328
- package/lib/telemetry/alerts.test.mjs +0 -109
- package/lib/telemetry/collect.test.mjs +0 -1274
- package/lib/tool-definitions-integration.test.mjs +0 -83
- package/lib/tool-definitions.test.mjs +0 -437
- package/lib/upgrade/global-refresh.test.mjs +0 -65
- package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
- package/lib/upgrade/post-steps.test.mjs +0 -200
- package/lib/upgrade/verify.test.mjs +0 -164
- package/lib/util/fetch-timeout.test.mjs +0 -202
- package/lib/util/reconnect.test.mjs +0 -369
- package/lib/util/unhandled.test.mjs +0 -216
- package/lib/voice/outbound.test.mjs +0 -69
- package/lib/voice/session-rotation.test.mjs +0 -114
- package/lib/voice/stt.test.mjs +0 -226
- package/lib/voice/voice.test.mjs +0 -990
- package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
- package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
- package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
- package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
- package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
- package/scripts/ci/check-skill-packs.test.mjs +0 -495
- package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
- package/scripts/ci/check.test.mjs +0 -194
- package/scripts/ci/conformance-org-api.test.mjs +0 -425
- package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
- package/scripts/collective/hook-runner.test.mjs +0 -173
- package/scripts/cost/fleet-digest.test.mjs +0 -207
- package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
- package/scripts/cost/track-claude-usage.test.mjs +0 -148
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
- package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
- package/scripts/daemon/agent-daemon.test.mjs +0 -995
- package/scripts/daemon/assurance-e2e.test.mjs +0 -613
- package/scripts/daemon/assurance.test.mjs +0 -1791
- package/scripts/daemon/board-mirror.test.mjs +0 -165
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
- package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
- package/scripts/daemon/cadence-consumer.test.mjs +0 -776
- package/scripts/daemon/cadence-handlers.test.mjs +0 -837
- package/scripts/daemon/classifier-identity.test.mjs +0 -137
- package/scripts/daemon/classifier.test.mjs +0 -266
- package/scripts/daemon/classify-kind.test.mjs +0 -40
- package/scripts/daemon/context-compiler.test.mjs +0 -300
- package/scripts/daemon/deliver.test.mjs +0 -564
- package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
- package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
- package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
- package/scripts/daemon/execution-ladder.test.mjs +0 -470
- package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
- package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
- package/scripts/daemon/inbox-deferral.test.mjs +0 -336
- package/scripts/daemon/inbox-wake.test.mjs +0 -199
- package/scripts/daemon/integration.test.mjs +0 -149
- package/scripts/daemon/lib/self-echo.test.mjs +0 -153
- package/scripts/daemon/lib/session-router.test.mjs +0 -295
- package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
- package/scripts/daemon/prompt-builder.test.mjs +0 -344
- package/scripts/daemon/responder-cost.test.mjs +0 -68
- package/scripts/daemon/responder-history.test.mjs +0 -185
- package/scripts/daemon/sdk-version.test.mjs +0 -31
- package/scripts/daemon/session-lock.test.mjs +0 -252
- package/scripts/daemon/session-outcomes.test.mjs +0 -533
- package/scripts/daemon/typing-registry.test.mjs +0 -102
- package/scripts/hooks/pre-send-audit.test.mjs +0 -354
- package/scripts/huddle/huddle-prompt.test.mjs +0 -176
- package/scripts/local-triggers/autoupdate.test.mjs +0 -518
- package/scripts/local-triggers/generate-plists.test.mjs +0 -456
- package/scripts/media-generation/brand-clause.test.mjs +0 -135
- package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
- package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
- package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
- package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
- package/scripts/poller/slack-socket-mode.test.mjs +0 -805
- package/scripts/poller-launchd/install.test.mjs +0 -243
- package/scripts/restore-from-backup.test.mjs +0 -181
- package/scripts/session/feed.test.mjs +0 -196
- package/scripts/session/supervisor-sh.test.mjs +0 -218
- package/scripts/session/supervisor.test.mjs +0 -482
- package/scripts/setup/configure-macos.test.mjs +0 -306
- package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
- package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
- package/scripts/setup/generate-capability.test.mjs +0 -134
- package/scripts/setup/init-agent.test.mjs +0 -370
- package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
- package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
- package/scripts/watchdog/memory-watchdog.test.mjs +0 -64
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/org/llm-token.mjs — the seat's short-lived cohort-llm token.
|
|
3
|
+
*
|
|
4
|
+
* A seat talks to the cohort-llm gateway with a `cst_…` seat token, never with
|
|
5
|
+
* its long-lived OrgApiKey and never with an Anthropic credential. The token is
|
|
6
|
+
* minted by exchanging the seat-bound OrgApiKey at `POST /cohort/v1/tokens`
|
|
7
|
+
* (hq docs/cohort-llm/design.md §4.3 lane 2, wire contract §4.8):
|
|
8
|
+
*
|
|
9
|
+
* POST <base>/cohort/v1/tokens
|
|
10
|
+
* Authorization: Bearer <seat-bound OrgApiKey>
|
|
11
|
+
* body {"purpose":"llm","ttlSeconds":3600}
|
|
12
|
+
* 200 {"token":"cst_<id>_<secret>","tokenType":"Bearer","expiresAt":"…",
|
|
13
|
+
* "orgId":"…","memberId":"…","seatBand":"…|null","baseUrl":"…"}
|
|
14
|
+
* 401 cohort_invalid_key · 403 cohort_seat_required · 503 cohort_unavailable
|
|
15
|
+
*
|
|
16
|
+
* What this module guarantees:
|
|
17
|
+
* - CACHED twice: in memory, and on disk at state/cohort-llm/token.json (mode
|
|
18
|
+
* 0600, through lib/fs-atomic.mjs — the repo's durable-write seam) so a
|
|
19
|
+
* second process on the seat (the apiKeyHelper, the cadence consumer) reuses
|
|
20
|
+
* the token instead of minting its own. The disk record carries a
|
|
21
|
+
* fingerprint of the OrgApiKey, never the key: a rotated key invalidates it.
|
|
22
|
+
* - REFRESHED at 80% of the token's lifetime, not at expiry, so a spawn never
|
|
23
|
+
* starts on a token about to die. A refresh that fails while the current
|
|
24
|
+
* token is still valid keeps serving it (and says so) — a gateway blip must
|
|
25
|
+
* not take down a seat whose credential still works.
|
|
26
|
+
* - SINGLE-FLIGHT: concurrent callers share one in-flight exchange.
|
|
27
|
+
* - TYPED returned errors: `{ok:false, error:{code, message, status}}` with
|
|
28
|
+
* code cohort_invalid_key | cohort_seat_required | cohort_unavailable |
|
|
29
|
+
* cohort_token_exchange_failed | cohort_seat_key_missing. Nothing throws.
|
|
30
|
+
* - NEVER LOGS A SECRET: every log line and error message passes through
|
|
31
|
+
* `redactSecrets`, which strips `cst_…`, `nlk_…`, `sk-ant-…` shapes and the
|
|
32
|
+
* literal key/token values.
|
|
33
|
+
*
|
|
34
|
+
* @module lib/org/llm-token
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
"use strict";
|
|
38
|
+
|
|
39
|
+
import { createHash } from "node:crypto";
|
|
40
|
+
import { existsSync, readFileSync, mkdirSync, openSync, closeSync, unlinkSync, writeFileSync } from "node:fs";
|
|
41
|
+
import { join, dirname, resolve } from "node:path";
|
|
42
|
+
import { fileURLToPath } from "node:url";
|
|
43
|
+
|
|
44
|
+
import { writeJsonAtomic } from "../fs-atomic.mjs";
|
|
45
|
+
import { loadOrgConfig } from "./client.mjs";
|
|
46
|
+
import { createQuotaClient, quotaStatePath } from "./quota.mjs";
|
|
47
|
+
|
|
48
|
+
/** The production gateway, used when neither config nor env names one. */
|
|
49
|
+
export const DEFAULT_LLM_BASE_URL = "https://llm.cohortapp.com";
|
|
50
|
+
/** Refresh once this fraction of the token's lifetime has elapsed. */
|
|
51
|
+
export const TOKEN_REFRESH_FRACTION = 0.8;
|
|
52
|
+
/** Requested lifetime; the contract caps it at 3600. */
|
|
53
|
+
export const DEFAULT_TOKEN_TTL_SECONDS = 3600;
|
|
54
|
+
/** How long one exchange may take before it counts as `cohort_unavailable`. */
|
|
55
|
+
export const EXCHANGE_TIMEOUT_MS = 10_000;
|
|
56
|
+
/**
|
|
57
|
+
* A FORCED exchange (after a 401) may run at most MAX_REMINTS_PER_WINDOW times
|
|
58
|
+
* per REMINT_WINDOW_MS for one seat key, across every process on the seat (the
|
|
59
|
+
* history is kept in the token record on disk, read and written under a lock).
|
|
60
|
+
* Without it each 401 minted a fresh gateway token, and a gateway that refuses
|
|
61
|
+
* every token became a mint storm (CF-136). A plain read that finds the token
|
|
62
|
+
* invalidated after a 401 is a forced exchange too.
|
|
63
|
+
*/
|
|
64
|
+
export const REMINT_WINDOW_MS = 5 * 60_000;
|
|
65
|
+
export const MAX_REMINTS_PER_WINDOW = 3;
|
|
66
|
+
/**
|
|
67
|
+
* A forced attempt that ends in `cohort_unavailable` (the token endpoint was
|
|
68
|
+
* down, so no token was issued) spends budget for this shorter window instead:
|
|
69
|
+
* an outage still bounds attempts, but a recovered gateway is not locked out
|
|
70
|
+
* for the full REMINT_WINDOW_MS.
|
|
71
|
+
*/
|
|
72
|
+
export const UNAVAILABLE_REMINT_WINDOW_MS = 60_000;
|
|
73
|
+
/**
|
|
74
|
+
* The re-mint caps as one named config (CF-143). The VALUES are the W7 defaults
|
|
75
|
+
* and stay as they are pending owner confirmation; `createTokenManager({
|
|
76
|
+
* remintCaps })` overrides them per manager (tests, or an owner decision
|
|
77
|
+
* landing in config). docs/engine/README.md "Retry caps" lists every cap.
|
|
78
|
+
*/
|
|
79
|
+
export const DEFAULT_REMINT_CAPS = Object.freeze({
|
|
80
|
+
max: MAX_REMINTS_PER_WINDOW,
|
|
81
|
+
windowMs: REMINT_WINDOW_MS,
|
|
82
|
+
unavailableWindowMs: UNAVAILABLE_REMINT_WINDOW_MS,
|
|
83
|
+
});
|
|
84
|
+
/**
|
|
85
|
+
* A token exchange that meets a transient outage (503/5xx or 408 at POST
|
|
86
|
+
* /cohort/v1/tokens, or a refused connection) is retried inside the SAME
|
|
87
|
+
* exchange (CF-143): at most `maxRetries` times, sleeping retry-after when the
|
|
88
|
+
* gateway sent one (never longer than `maxRetryAfterMs` — a longer one ends the
|
|
89
|
+
* retries) else the `backoffMs` ladder. `deadlineMs` bounds the WHOLE exchange,
|
|
90
|
+
* sends included, from the first send: a retry starts only when its sleep plus
|
|
91
|
+
* `minSendMs` still fits, and every send's request timeout is cut to what is
|
|
92
|
+
* left of the deadline, so a slow outage ends by `deadlineMs` and the engine
|
|
93
|
+
* (which kills its token helper at 20 s, TOKEN_HELPER_TIMEOUT_MS) reads the
|
|
94
|
+
* helper's own `cohort_unavailable` instead of a kill. An exchange issues at
|
|
95
|
+
* most one token and spends the re-mint budget once, whatever it retries, so
|
|
96
|
+
* this cannot become a mint storm. A request that times out (our own
|
|
97
|
+
* EXCHANGE_TIMEOUT_MS, or the deadline cut) is not retried.
|
|
98
|
+
*/
|
|
99
|
+
export const DEFAULT_EXCHANGE_RETRY = Object.freeze({
|
|
100
|
+
maxRetries: 2,
|
|
101
|
+
backoffMs: Object.freeze([500, 1000]),
|
|
102
|
+
maxRetryAfterMs: 5_000,
|
|
103
|
+
deadlineMs: 15_000,
|
|
104
|
+
minSendMs: 1_000,
|
|
105
|
+
});
|
|
106
|
+
/** The token record's cross-process lock: how long to wait, and when a holder counts as dead. */
|
|
107
|
+
const RECORD_LOCK_TIMEOUT_MS = 2_000;
|
|
108
|
+
const RECORD_LOCK_SPIN_MS = 5;
|
|
109
|
+
/** The env var the engine sets for the helper: the fingerprint of the token a 401 refused. */
|
|
110
|
+
export const REJECTED_TOKEN_FP_ENV = "COHORT_LLM_TOKEN_REJECTED_FP";
|
|
111
|
+
|
|
112
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
113
|
+
/** The helper Claude Code runs as `apiKeyHelper` on a cohort retarget. */
|
|
114
|
+
export const API_KEY_HELPER_PATH = join(REPO_ROOT, "scripts", "cohort-llm", "api-key-helper.mjs");
|
|
115
|
+
|
|
116
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Pure: configuration
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The gateway base URL. Config `org.cohort.llmBaseUrl` first, then
|
|
124
|
+
* `COHORT_LLM_BASE_URL`, then the production default. Trailing slashes are
|
|
125
|
+
* dropped so `<base>/v1` and `<base>/cohort/v1/…` are always well formed.
|
|
126
|
+
* @param {{cfg?:object, env?:object}} [o]
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
export function resolveLlmBaseUrl({ cfg, env = process.env } = {}) {
|
|
130
|
+
const n = (cfg && cfg.org && cfg.org.cohort) || {};
|
|
131
|
+
const picked = [n.llmBaseUrl, env && env.COHORT_LLM_BASE_URL].find(nonEmpty) || DEFAULT_LLM_BASE_URL;
|
|
132
|
+
return String(picked).trim().replace(/\/+$/, "");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The catalog endpoints for the cohort provider at a base URL (design §8.3:
|
|
137
|
+
* both wires point at the gateway).
|
|
138
|
+
* @param {string} baseUrl
|
|
139
|
+
*/
|
|
140
|
+
export function cohortEndpoints(baseUrl) {
|
|
141
|
+
const b = String(baseUrl || DEFAULT_LLM_BASE_URL).replace(/\/+$/, "");
|
|
142
|
+
return { openai: `${b}/v1`, anthropic: b };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The seat's OrgApiKey — the same resolution order as lib/org/client.mjs
|
|
147
|
+
* `configFromAgent` (config token → COHORT_API_TOKEN → COHORT_TOKEN →
|
|
148
|
+
* COHORT_API_KEY), with the env injected, and the agent's `.env` as a last
|
|
149
|
+
* resort for a child (the apiKeyHelper) whose env was scrubbed of credentials.
|
|
150
|
+
* @param {{cfg?:object, env?:object, dotEnv?:object}} [o]
|
|
151
|
+
* @returns {string} "" when the seat has none
|
|
152
|
+
*/
|
|
153
|
+
export function seatApiKey({ cfg, env = process.env, dotEnv } = {}) {
|
|
154
|
+
const n = (cfg && cfg.org && cfg.org.cohort) || {};
|
|
155
|
+
const e = env || {};
|
|
156
|
+
const d = dotEnv || {};
|
|
157
|
+
const picked = [n.token, e.COHORT_API_TOKEN, e.COHORT_TOKEN, e.COHORT_API_KEY, d.COHORT_API_TOKEN, d.COHORT_TOKEN, d.COHORT_API_KEY].find(nonEmpty);
|
|
158
|
+
return picked ? String(picked).trim() : "";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Where a seat's token record lives. */
|
|
162
|
+
export function tokenStatePath(agentRoot) {
|
|
163
|
+
return join(resolve(agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd()), "state", "cohort-llm", "token.json");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A short, non-reversible fingerprint of a credential (for cache keys and logs). */
|
|
167
|
+
export function keyFingerprint(secret) {
|
|
168
|
+
return createHash("sha256").update(String(secret || "")).digest("hex").slice(0, 16);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Single-quote a string for /bin/sh (Claude Code runs apiKeyHelper in a shell).
|
|
173
|
+
* @param {string} s
|
|
174
|
+
*/
|
|
175
|
+
function shq(s) {
|
|
176
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The `apiKeyHelper` command for a seat: node + the helper + the agent root.
|
|
181
|
+
* No secret is ever on this command line — the helper reads the cached token,
|
|
182
|
+
* or the seat's key from config/.env, itself.
|
|
183
|
+
* @param {{agentRoot:string, nodeBin?:string, helperPath?:string}} o
|
|
184
|
+
*/
|
|
185
|
+
export function apiKeyHelperCommand({ agentRoot, nodeBin = process.execPath, helperPath = API_KEY_HELPER_PATH } = {}) {
|
|
186
|
+
return `${shq(nodeBin)} ${shq(helperPath)} --agent-root ${shq(resolve(agentRoot || process.cwd()))}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Pure: redaction, refresh policy, error classification
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
const SECRET_SHAPES = [/cst_[A-Za-z0-9_\-]+/g, /nlk_[A-Za-z0-9_\-]+/g, /sk-ant-[A-Za-z0-9_\-]+/g];
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Remove every secret from a string: the known credential shapes, plus any
|
|
197
|
+
* literal value passed in `secrets` (an OrgApiKey need not look like nlk_…).
|
|
198
|
+
* @param {unknown} text
|
|
199
|
+
* @param {string[]} [secrets]
|
|
200
|
+
* @returns {string}
|
|
201
|
+
*/
|
|
202
|
+
export function redactSecrets(text, secrets = []) {
|
|
203
|
+
let out = String(text ?? "");
|
|
204
|
+
for (const s of secrets) {
|
|
205
|
+
if (nonEmpty(s) && s.length >= 6) out = out.split(s).join("***");
|
|
206
|
+
}
|
|
207
|
+
for (const re of SECRET_SHAPES) out = out.replace(re, "***");
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Has this record reached its refresh point (80% of lifetime) or expired?
|
|
213
|
+
* A record with unparseable times always needs a refresh.
|
|
214
|
+
* @param {object|null} rec {token, issuedAt, expiresAt}
|
|
215
|
+
* @param {number} nowMs
|
|
216
|
+
* @param {number} [fraction]
|
|
217
|
+
*/
|
|
218
|
+
export function needsRefresh(rec, nowMs, fraction = TOKEN_REFRESH_FRACTION) {
|
|
219
|
+
if (!rec || !nonEmpty(rec.token)) return true;
|
|
220
|
+
const issued = Date.parse(rec.issuedAt);
|
|
221
|
+
const expires = Date.parse(rec.expiresAt);
|
|
222
|
+
if (!Number.isFinite(issued) || !Number.isFinite(expires)) return true;
|
|
223
|
+
if (expires <= issued) return nowMs >= expires;
|
|
224
|
+
return nowMs >= issued + fraction * (expires - issued);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Is the token still valid at all (not yet expired)? */
|
|
228
|
+
export function isUnexpired(rec, nowMs) {
|
|
229
|
+
if (!rec || !nonEmpty(rec.token)) return false;
|
|
230
|
+
const expires = Date.parse(rec.expiresAt);
|
|
231
|
+
return Number.isFinite(expires) && nowMs < expires;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** In-window entries of a history, ascending. A future-stamped entry (clock skew) still counts. */
|
|
235
|
+
function inWindow(history, nowMs, windowMs) {
|
|
236
|
+
return (Array.isArray(history) ? history : [])
|
|
237
|
+
.filter((at) => Number.isFinite(at) && at > nowMs - windowMs)
|
|
238
|
+
.sort((a, b) => a - b);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The sliding-window re-mint budget. PURE.
|
|
243
|
+
* @param {unknown} history epoch ms of earlier forced mints
|
|
244
|
+
* @param {number} nowMs
|
|
245
|
+
* @param {{max?:number, windowMs?:number, unavailable?:unknown, unavailableWindowMs?:number}} [o]
|
|
246
|
+
* `unavailable`: epoch ms of forced attempts that ended in cohort_unavailable,
|
|
247
|
+
* counted for the shorter `unavailableWindowMs`.
|
|
248
|
+
* @returns {{allowed:boolean, recent:number[], retryAt:number|null}}
|
|
249
|
+
*/
|
|
250
|
+
export function remintBudget(history, nowMs, { max = MAX_REMINTS_PER_WINDOW, windowMs = REMINT_WINDOW_MS, unavailable = [], unavailableWindowMs = UNAVAILABLE_REMINT_WINDOW_MS } = {}) {
|
|
251
|
+
const recent = inWindow(history, nowMs, windowMs);
|
|
252
|
+
const expiries = [
|
|
253
|
+
...recent.map((at) => at + windowMs),
|
|
254
|
+
...inWindow(unavailable, nowMs, unavailableWindowMs).map((at) => at + unavailableWindowMs),
|
|
255
|
+
].sort((a, b) => a - b);
|
|
256
|
+
if (expiries.length < max) return { allowed: true, recent, retryAt: null };
|
|
257
|
+
return { allowed: false, recent, retryAt: expiries[expiries.length - max] };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Merge copies of one re-mint history. PURE. Every copy is some process's view
|
|
262
|
+
* of the same append-only list, so a timestamp appears as many times as the
|
|
263
|
+
* copy that holds it most often (two mints in the same millisecond stay two).
|
|
264
|
+
* @param {...unknown} copies
|
|
265
|
+
* @returns {number[]} ascending
|
|
266
|
+
*/
|
|
267
|
+
export function mergeRemintHistory(...copies) {
|
|
268
|
+
const counts = new Map();
|
|
269
|
+
for (const copy of copies) {
|
|
270
|
+
if (!Array.isArray(copy)) continue;
|
|
271
|
+
const here = new Map();
|
|
272
|
+
for (const at of copy) if (Number.isFinite(at)) here.set(at, (here.get(at) || 0) + 1);
|
|
273
|
+
for (const [at, n] of here) counts.set(at, Math.max(counts.get(at) || 0, n));
|
|
274
|
+
}
|
|
275
|
+
const out = [];
|
|
276
|
+
for (const [at, n] of counts) for (let i = 0; i < n; i++) out.push(at);
|
|
277
|
+
return out.sort((a, b) => a - b);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* How long to wait before resending a token exchange that met a transient
|
|
282
|
+
* outage, or null to stop retrying. PURE.
|
|
283
|
+
* @param {{attempt:number, retryAfterMs?:number|null, elapsedMs?:number}} s
|
|
284
|
+
* attempt: retries already made (0 before the first retry)
|
|
285
|
+
* @param {{maxRetries:number, backoffMs:readonly number[], maxRetryAfterMs:number, deadlineMs:number, minSendMs?:number}} [caps]
|
|
286
|
+
* a retry is refused unless elapsed + wait + minSendMs ≤ deadlineMs, so the
|
|
287
|
+
* send it leads to still has time to complete inside the deadline
|
|
288
|
+
* @returns {number|null}
|
|
289
|
+
*/
|
|
290
|
+
export function exchangeRetryDelay({ attempt, retryAfterMs = null, elapsedMs = 0 }, caps = DEFAULT_EXCHANGE_RETRY) {
|
|
291
|
+
if (!Number.isInteger(attempt) || attempt < 0 || attempt >= caps.maxRetries) return null;
|
|
292
|
+
let wait;
|
|
293
|
+
if (Number.isFinite(retryAfterMs) && retryAfterMs >= 0) {
|
|
294
|
+
if (retryAfterMs > caps.maxRetryAfterMs) return null; // not slept through
|
|
295
|
+
wait = retryAfterMs;
|
|
296
|
+
} else {
|
|
297
|
+
const ladder = caps.backoffMs;
|
|
298
|
+
wait = ladder.length ? ladder[Math.min(attempt, ladder.length - 1)] : 0;
|
|
299
|
+
}
|
|
300
|
+
const elapsed = Number.isFinite(elapsedMs) && elapsedMs > 0 ? elapsedMs : 0;
|
|
301
|
+
const minSend = Number.isFinite(caps.minSendMs) && caps.minSendMs > 0 ? caps.minSendMs : 0;
|
|
302
|
+
if (elapsed + wait + minSend > caps.deadlineMs) return null;
|
|
303
|
+
return wait;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** retry-after (seconds or an HTTP date) in ms, or null. PURE. */
|
|
307
|
+
export function parseRetryAfterMs(value, nowMs) {
|
|
308
|
+
if (value == null || value === "") return null;
|
|
309
|
+
const secs = Number(value);
|
|
310
|
+
if (Number.isFinite(secs)) return secs >= 0 ? Math.round(secs * 1000) : null;
|
|
311
|
+
const at = Date.parse(String(value));
|
|
312
|
+
return Number.isFinite(at) ? Math.max(0, at - nowMs) : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const EXCHANGE_CODES = new Set(["cohort_invalid_key", "cohort_seat_required", "cohort_unavailable"]);
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Map a failed exchange onto the typed error vocabulary. A cohort refusal code
|
|
319
|
+
* in the body wins; otherwise the status decides.
|
|
320
|
+
* @param {number|null} status
|
|
321
|
+
* @param {any} body parsed JSON body (or null)
|
|
322
|
+
* @returns {{code:string, message:string, status:number|null}}
|
|
323
|
+
*/
|
|
324
|
+
export function classifyExchangeFailure(status, body) {
|
|
325
|
+
const b = body && typeof body === "object" ? body : {};
|
|
326
|
+
const bodyCode = (b.cohort && b.cohort.code) || (b.error && b.error.code) || null;
|
|
327
|
+
const message = (b.error && typeof b.error.message === "string" && b.error.message) || null;
|
|
328
|
+
let code;
|
|
329
|
+
if (typeof bodyCode === "string" && EXCHANGE_CODES.has(bodyCode)) code = bodyCode;
|
|
330
|
+
else if (status === 401) code = "cohort_invalid_key";
|
|
331
|
+
else if (status === 403) code = "cohort_seat_required";
|
|
332
|
+
// 408: the gateway's body idle timeout — the request stalled, the key is fine.
|
|
333
|
+
else if (status === 408 || status === 503 || status === null || status >= 500) code = "cohort_unavailable";
|
|
334
|
+
else code = "cohort_token_exchange_failed";
|
|
335
|
+
const why = {
|
|
336
|
+
cohort_invalid_key: "the seat's OrgApiKey was refused by cohort-llm",
|
|
337
|
+
cohort_seat_required: "cohort-llm tokens need a seat-bound key (this key is seatless or a guest key)",
|
|
338
|
+
cohort_unavailable: "cohort-llm is unavailable",
|
|
339
|
+
cohort_token_exchange_failed: `the token exchange failed (HTTP ${status})`,
|
|
340
|
+
}[code];
|
|
341
|
+
return { code, message: message ? `${why}: ${message}` : why, status };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Edge: the token manager
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
function readRecord(path) {
|
|
349
|
+
try {
|
|
350
|
+
if (!existsSync(path)) return null;
|
|
351
|
+
const r = JSON.parse(readFileSync(path, "utf-8"));
|
|
352
|
+
return r && typeof r === "object" ? r : null;
|
|
353
|
+
} catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function busyWaitMs(ms) {
|
|
359
|
+
const end = Date.now() + ms;
|
|
360
|
+
while (Date.now() < end) { /* spin */ }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* O_EXCL lock on `<record>.lock`, the same discipline as
|
|
365
|
+
* lib/model-router/health.mjs: spin on EEXIST, steal a lock older than the
|
|
366
|
+
* timeout (its holder died). Wall-clock time, never an injected clock: the lock
|
|
367
|
+
* is about real processes. null when it cannot be taken.
|
|
368
|
+
*/
|
|
369
|
+
function acquireRecordLock(lockPath) {
|
|
370
|
+
const deadline = Date.now() + RECORD_LOCK_TIMEOUT_MS;
|
|
371
|
+
for (;;) {
|
|
372
|
+
try {
|
|
373
|
+
const fd = openSync(lockPath, "wx");
|
|
374
|
+
try { writeFileSync(fd, JSON.stringify({ at: Date.now(), pid: process.pid })); } catch { /* stamp is best-effort */ }
|
|
375
|
+
return fd;
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (!err || err.code !== "EEXIST") return null;
|
|
378
|
+
try {
|
|
379
|
+
const heldAt = Number(JSON.parse(readFileSync(lockPath, "utf-8")).at);
|
|
380
|
+
if (Number.isFinite(heldAt) && Date.now() - heldAt > RECORD_LOCK_TIMEOUT_MS) {
|
|
381
|
+
try { unlinkSync(lockPath); } catch { /* another process stole it first */ }
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
} catch { /* unreadable (being written) — spin */ }
|
|
385
|
+
if (Date.now() >= deadline) return null;
|
|
386
|
+
busyWaitMs(RECORD_LOCK_SPIN_MS);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* @param {object} o
|
|
393
|
+
* @param {string} o.baseUrl gateway base (no trailing slash)
|
|
394
|
+
* @param {string} o.apiKey the seat's OrgApiKey
|
|
395
|
+
* @param {string} [o.statePath] on-disk cache (null disables disk)
|
|
396
|
+
* @param {typeof fetch} [o.fetchImpl]
|
|
397
|
+
* @param {() => number} [o.now]
|
|
398
|
+
* @param {number} [o.ttlSeconds]
|
|
399
|
+
* @param {number} [o.timeoutMs]
|
|
400
|
+
* @param {(level:string, msg:string) => void} [o.log]
|
|
401
|
+
* @param {(ms:number) => Promise<void>} [o.sleep] the exchange's retry sleep (default a real timer)
|
|
402
|
+
* @param {Partial<typeof DEFAULT_REMINT_CAPS>} [o.remintCaps]
|
|
403
|
+
* @param {Partial<typeof DEFAULT_EXCHANGE_RETRY>} [o.exchangeRetry]
|
|
404
|
+
* @param {(ms:number) => AbortSignal} [o.timeoutSignal] one send's timeout signal (default AbortSignal.timeout; tests inject a clocked one)
|
|
405
|
+
*/
|
|
406
|
+
export function createTokenManager(o = {}) {
|
|
407
|
+
const baseUrl = String(o.baseUrl || DEFAULT_LLM_BASE_URL).replace(/\/+$/, "");
|
|
408
|
+
const apiKey = nonEmpty(o.apiKey) ? o.apiKey.trim() : "";
|
|
409
|
+
const keyFp = apiKey ? keyFingerprint(apiKey) : null;
|
|
410
|
+
const statePath = o.statePath === undefined ? null : o.statePath;
|
|
411
|
+
const now = typeof o.now === "function" ? o.now : Date.now;
|
|
412
|
+
const ttlSeconds = Math.min(DEFAULT_TOKEN_TTL_SECONDS, Math.max(60, Number(o.ttlSeconds) || DEFAULT_TOKEN_TTL_SECONDS));
|
|
413
|
+
const timeoutMs = Number(o.timeoutMs) > 0 ? Number(o.timeoutMs) : EXCHANGE_TIMEOUT_MS;
|
|
414
|
+
const fetchImpl = typeof o.fetchImpl === "function" ? o.fetchImpl : globalThis.fetch;
|
|
415
|
+
const sleep = typeof o.sleep === "function" ? o.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
|
|
416
|
+
const caps = { ...DEFAULT_REMINT_CAPS, ...(o.remintCaps || {}) };
|
|
417
|
+
const retryCaps = { ...DEFAULT_EXCHANGE_RETRY, ...(o.exchangeRetry || {}) };
|
|
418
|
+
const timeoutSignal = typeof o.timeoutSignal === "function"
|
|
419
|
+
? o.timeoutSignal
|
|
420
|
+
: (ms) => (typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(ms) : undefined);
|
|
421
|
+
let mem = null;
|
|
422
|
+
let inflight = null;
|
|
423
|
+
/**
|
|
424
|
+
* Forced-mint history this process has seen, merged with the disk record's on
|
|
425
|
+
* every locked write: `remints` (REMINT_WINDOW_MS) and `unavailable` — forced
|
|
426
|
+
* attempts that ended in cohort_unavailable (UNAVAILABLE_REMINT_WINDOW_MS).
|
|
427
|
+
*/
|
|
428
|
+
let hist = { remints: [], unavailable: [] };
|
|
429
|
+
/** This process invalidated the token and has not minted since (the disk-less twin of `invalidatedAt`). */
|
|
430
|
+
let invalidatedHere = false;
|
|
431
|
+
|
|
432
|
+
const secrets = () => [apiKey, mem && mem.token].filter(Boolean);
|
|
433
|
+
const log = (level, msg) => {
|
|
434
|
+
if (typeof o.log !== "function") return;
|
|
435
|
+
try { o.log(level, redactSecrets(msg, secrets())); } catch { /* never throw from logging */ }
|
|
436
|
+
};
|
|
437
|
+
const fail = (err) => ({ ok: false, error: { ...err, message: redactSecrets(err.message, secrets()) } });
|
|
438
|
+
const view = (rec, extra = {}) => ({
|
|
439
|
+
ok: true,
|
|
440
|
+
token: rec.token,
|
|
441
|
+
expiresAt: rec.expiresAt,
|
|
442
|
+
orgId: rec.orgId ?? null,
|
|
443
|
+
memberId: rec.memberId ?? null,
|
|
444
|
+
seatBand: rec.seatBand ?? null,
|
|
445
|
+
baseUrl: rec.baseUrl || baseUrl,
|
|
446
|
+
...extra,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
/** The best record we hold for THIS key and base URL (memory, then disk). */
|
|
450
|
+
function current() {
|
|
451
|
+
if (mem && mem.keyFp === keyFp && mem.baseUrl === baseUrl) return mem;
|
|
452
|
+
if (!statePath) return null;
|
|
453
|
+
const disk = readRecord(statePath);
|
|
454
|
+
if (disk && disk.keyFp === keyFp && disk.baseUrl === baseUrl && nonEmpty(disk.token)) {
|
|
455
|
+
mem = disk;
|
|
456
|
+
return disk;
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const mineOf = (disk) => (disk && disk.keyFp === keyFp ? disk : null);
|
|
462
|
+
const histFields = (h) => ({ remints: h.remints, remintsUnavailable: h.unavailable });
|
|
463
|
+
|
|
464
|
+
/** This process's history merged with the disk record's, pruned to the windows. */
|
|
465
|
+
function mergedHist(disk, t) {
|
|
466
|
+
const m = mineOf(disk);
|
|
467
|
+
return {
|
|
468
|
+
remints: inWindow(mergeRemintHistory(hist.remints, m && m.remints), t, caps.windowMs),
|
|
469
|
+
unavailable: inWindow(mergeRemintHistory(hist.unavailable, m && m.remintsUnavailable), t, caps.unavailableWindowMs),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Read-modify-write the disk record under its lock: `mutator(disk)` returns
|
|
475
|
+
* the record to write, or null to leave it. Without a state path the mutator
|
|
476
|
+
* runs against null and nothing is written. When the lock cannot be taken the
|
|
477
|
+
* write still happens, unlocked (a slightly racy history beats none). Never
|
|
478
|
+
* throws.
|
|
479
|
+
* @param {(disk:object|null) => object|null} mutator
|
|
480
|
+
*/
|
|
481
|
+
function withRecord(mutator) {
|
|
482
|
+
if (!statePath) { mutator(null); return; }
|
|
483
|
+
const lockPath = `${statePath}.lock`;
|
|
484
|
+
let fd = null;
|
|
485
|
+
try {
|
|
486
|
+
mkdirSync(dirname(statePath), { recursive: true, mode: 0o700 });
|
|
487
|
+
fd = acquireRecordLock(lockPath);
|
|
488
|
+
} catch { fd = null; }
|
|
489
|
+
try {
|
|
490
|
+
const next = mutator(readRecord(statePath));
|
|
491
|
+
if (next) writeJsonAtomic(statePath, next, { mode: 0o600 });
|
|
492
|
+
} catch (err) {
|
|
493
|
+
log("warn", `could not persist the cohort-llm token record (${err && err.message ? err.message : err}); continuing in memory`);
|
|
494
|
+
} finally {
|
|
495
|
+
if (fd !== null) {
|
|
496
|
+
try { closeSync(fd); } catch { /* */ }
|
|
497
|
+
try { unlinkSync(lockPath); } catch { /* */ }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Was the token invalidated after a 401 with no mint since (here, or by any process on the seat)? */
|
|
503
|
+
function wasInvalidated() {
|
|
504
|
+
if (invalidatedHere) return true;
|
|
505
|
+
const m = statePath ? mineOf(readRecord(statePath)) : null;
|
|
506
|
+
return !!(m && !nonEmpty(m.token) && m.invalidatedAt);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** @param {{forced?:boolean}} [xo] */
|
|
510
|
+
async function exchange(xo = {}) {
|
|
511
|
+
if (!apiKey) {
|
|
512
|
+
return fail({ code: "cohort_seat_key_missing", message: "this seat has no OrgApiKey (org.cohort.token / COHORT_API_KEY), so it cannot mint a cohort-llm token", status: null });
|
|
513
|
+
}
|
|
514
|
+
if (typeof fetchImpl !== "function") {
|
|
515
|
+
return fail({ code: "cohort_unavailable", message: "no fetch implementation available", status: null });
|
|
516
|
+
}
|
|
517
|
+
const issuedAt = now();
|
|
518
|
+
if (xo.forced) {
|
|
519
|
+
// Check and spend the budget in ONE locked step, so two processes cannot
|
|
520
|
+
// both read "2 of 3 used" and both mint. Counted when attempted, and
|
|
521
|
+
// persisted before the fetch: a helper that dies mid-mint still spent it.
|
|
522
|
+
let capped = null;
|
|
523
|
+
withRecord((disk) => {
|
|
524
|
+
const h = mergedHist(disk, issuedAt);
|
|
525
|
+
const budget = remintBudget(h.remints, issuedAt, { max: caps.max, windowMs: caps.windowMs, unavailable: h.unavailable, unavailableWindowMs: caps.unavailableWindowMs });
|
|
526
|
+
if (!budget.allowed) {
|
|
527
|
+
hist = h;
|
|
528
|
+
capped = budget;
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
hist = { remints: [...h.remints, issuedAt], unavailable: h.unavailable };
|
|
532
|
+
return { ...(mineOf(disk) || { keyFp }), ...histFields(hist) };
|
|
533
|
+
});
|
|
534
|
+
if (capped) {
|
|
535
|
+
return fail({
|
|
536
|
+
code: "cohort_token_remint_capped",
|
|
537
|
+
message: `cohort-llm refused ${caps.max} freshly minted tokens within ${Math.round(caps.windowMs / 60000)} minutes; not minting again before ${new Date(capped.retryAt).toISOString()}`,
|
|
538
|
+
status: null,
|
|
539
|
+
retryAt: capped.retryAt,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/** A forced attempt that issued no token because cohort-llm was down moves to the short window. */
|
|
544
|
+
const failExchange = (err) => {
|
|
545
|
+
if (xo.forced && err.code === "cohort_unavailable") {
|
|
546
|
+
const dropOne = (arr) => {
|
|
547
|
+
const list = Array.isArray(arr) ? arr : [];
|
|
548
|
+
const i = list.indexOf(issuedAt);
|
|
549
|
+
return i < 0 ? list : [...list.slice(0, i), ...list.slice(i + 1)];
|
|
550
|
+
};
|
|
551
|
+
hist = { remints: dropOne(hist.remints), unavailable: [...hist.unavailable, issuedAt] };
|
|
552
|
+
withRecord((disk) => {
|
|
553
|
+
const m = mineOf(disk);
|
|
554
|
+
const t = now();
|
|
555
|
+
hist = {
|
|
556
|
+
remints: inWindow(mergeRemintHistory(hist.remints, m && dropOne(m.remints)), t, caps.windowMs),
|
|
557
|
+
unavailable: inWindow(mergeRemintHistory(hist.unavailable, m && m.remintsUnavailable), t, caps.unavailableWindowMs),
|
|
558
|
+
};
|
|
559
|
+
return { ...(m || { keyFp }), ...histFields(hist) };
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
return fail(err);
|
|
563
|
+
};
|
|
564
|
+
// One exchange, bounded retries on a transient outage (DEFAULT_EXCHANGE_RETRY).
|
|
565
|
+
// The budget above was spent once for the whole exchange; at most one token
|
|
566
|
+
// is issued, so a retry here never counts as, or becomes, another mint.
|
|
567
|
+
let res;
|
|
568
|
+
let body = null;
|
|
569
|
+
for (let retries = 0; ; retries++) {
|
|
570
|
+
// Every send, and the body read it covers, ends by the exchange deadline:
|
|
571
|
+
// the request timeout is cut to what is left of it, so retries can never
|
|
572
|
+
// carry the helper past the engine's kill (TOKEN_HELPER_TIMEOUT_MS).
|
|
573
|
+
const leftMs = retryCaps.deadlineMs - (now() - issuedAt);
|
|
574
|
+
if (retries > 0 && leftMs <= 0) {
|
|
575
|
+
return failExchange({ code: "cohort_unavailable", message: `cohort-llm token endpoint still unavailable after ${retries} retries; the ${retryCaps.deadlineMs} ms exchange deadline has passed`, status: null });
|
|
576
|
+
}
|
|
577
|
+
const sendTimeoutMs = Math.max(1, Math.min(timeoutMs, leftMs));
|
|
578
|
+
const cutByDeadline = sendTimeoutMs < timeoutMs;
|
|
579
|
+
let transportError = null;
|
|
580
|
+
try {
|
|
581
|
+
res = await fetchImpl(`${baseUrl}/cohort/v1/tokens`, {
|
|
582
|
+
method: "POST",
|
|
583
|
+
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json", accept: "application/json" },
|
|
584
|
+
body: JSON.stringify({ purpose: "llm", ttlSeconds }),
|
|
585
|
+
signal: timeoutSignal(sendTimeoutMs),
|
|
586
|
+
});
|
|
587
|
+
body = null;
|
|
588
|
+
try { body = await res.json(); } catch (err) {
|
|
589
|
+
// A body read aborted by the timeout is a transport failure, not an empty body.
|
|
590
|
+
if (err && (err.name === "TimeoutError" || err.name === "AbortError")) throw err;
|
|
591
|
+
body = null;
|
|
592
|
+
}
|
|
593
|
+
} catch (err) {
|
|
594
|
+
transportError = err || new Error("fetch failed");
|
|
595
|
+
}
|
|
596
|
+
const elapsedMs = now() - issuedAt;
|
|
597
|
+
if (transportError) {
|
|
598
|
+
// A timed-out send (EXCHANGE_TIMEOUT_MS, or cut short by the deadline) is not retried.
|
|
599
|
+
const timedOut = transportError.name === "TimeoutError" || transportError.name === "AbortError";
|
|
600
|
+
const wait = timedOut ? null : exchangeRetryDelay({ attempt: retries, elapsedMs }, retryCaps);
|
|
601
|
+
if (wait !== null) {
|
|
602
|
+
log("warn", `cohort-llm token exchange did not complete (${transportError.message || transportError}); retrying in ${wait} ms`);
|
|
603
|
+
await sleep(wait);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const why = timedOut && cutByDeadline
|
|
607
|
+
? `no response before the ${retryCaps.deadlineMs} ms exchange deadline (after ${retries} retries)`
|
|
608
|
+
: `${transportError.message || transportError}`;
|
|
609
|
+
return failExchange({ code: "cohort_unavailable", message: `cohort-llm token exchange did not complete: ${why}`, status: null });
|
|
610
|
+
}
|
|
611
|
+
if (res.ok) break;
|
|
612
|
+
const failure = classifyExchangeFailure(res.status, body);
|
|
613
|
+
const header = (name) => (res.headers && typeof res.headers.get === "function" ? res.headers.get(name) : null);
|
|
614
|
+
if (failure.code === "cohort_unavailable" && header("x-should-retry") !== "false") {
|
|
615
|
+
const wait = exchangeRetryDelay({ attempt: retries, retryAfterMs: parseRetryAfterMs(header("retry-after"), now()), elapsedMs }, retryCaps);
|
|
616
|
+
if (wait !== null) {
|
|
617
|
+
log("warn", `cohort-llm token endpoint answered ${res.status} (cohort_unavailable); retrying in ${wait} ms`);
|
|
618
|
+
await sleep(wait);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return failExchange(failure);
|
|
623
|
+
}
|
|
624
|
+
const token = body && body.token;
|
|
625
|
+
const expiresMs = body ? Date.parse(body.expiresAt) : NaN;
|
|
626
|
+
if (!nonEmpty(token) || !Number.isFinite(expiresMs)) {
|
|
627
|
+
return fail({ code: "cohort_token_exchange_failed", message: "cohort-llm returned a token response without a token or a parseable expiresAt", status: res.status });
|
|
628
|
+
}
|
|
629
|
+
const rec = {
|
|
630
|
+
token,
|
|
631
|
+
tokenType: body.tokenType || "Bearer",
|
|
632
|
+
expiresAt: new Date(expiresMs).toISOString(),
|
|
633
|
+
issuedAt: new Date(issuedAt).toISOString(),
|
|
634
|
+
orgId: body.orgId ?? null,
|
|
635
|
+
memberId: body.memberId ?? null,
|
|
636
|
+
seatBand: body.seatBand ?? null,
|
|
637
|
+
baseUrl,
|
|
638
|
+
keyFp,
|
|
639
|
+
...histFields(hist),
|
|
640
|
+
};
|
|
641
|
+
// Re-read under the lock and merge: another process may have spent budget
|
|
642
|
+
// while this fetch was in flight, and this write must not erase it.
|
|
643
|
+
withRecord((disk) => {
|
|
644
|
+
hist = mergedHist(disk, now());
|
|
645
|
+
Object.assign(rec, histFields(hist));
|
|
646
|
+
return rec;
|
|
647
|
+
});
|
|
648
|
+
mem = rec;
|
|
649
|
+
invalidatedHere = false;
|
|
650
|
+
log("info", `minted a cohort-llm seat token (key ${keyFp}, expires ${rec.expiresAt})`);
|
|
651
|
+
return view(rec, { refreshed: true });
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* A token fit to use now: cached when it is before its refresh point, else a
|
|
656
|
+
* (single-flight) exchange. A failed refresh falls back to a still-valid
|
|
657
|
+
* token, marked `stale:true`.
|
|
658
|
+
*
|
|
659
|
+
* After a 401 pass the refused token as `rejected` (or its fingerprint as
|
|
660
|
+
* `rejectedFp`, which is all the engine's helper is given). When the cached
|
|
661
|
+
* token is no longer that one — another request or process already replaced
|
|
662
|
+
* it — the replacement is served with no exchange. Otherwise the exchange is
|
|
663
|
+
* FORCED and counts against the re-mint budget (MAX_REMINTS_PER_WINDOW per
|
|
664
|
+
* REMINT_WINDOW_MS); past it the answer is `cohort_token_remint_capped`.
|
|
665
|
+
* `force:true` alone is a forced exchange under the same budget.
|
|
666
|
+
* @param {{force?:boolean, rejected?:string, rejectedFp?:string}} [opts]
|
|
667
|
+
*/
|
|
668
|
+
async function getToken(opts = {}) {
|
|
669
|
+
let rec = current();
|
|
670
|
+
const t = now();
|
|
671
|
+
const rejectedFp = nonEmpty(opts.rejectedFp) ? opts.rejectedFp.trim() : nonEmpty(opts.rejected) ? keyFingerprint(opts.rejected) : null;
|
|
672
|
+
if (rejectedFp && rec && keyFingerprint(rec.token) === rejectedFp && statePath) {
|
|
673
|
+
// This process's memory holds the refused token; another process may
|
|
674
|
+
// already have written its replacement to disk.
|
|
675
|
+
const disk = readRecord(statePath);
|
|
676
|
+
if (disk && disk.keyFp === keyFp && disk.baseUrl === baseUrl && nonEmpty(disk.token) && keyFingerprint(disk.token) !== rejectedFp && isUnexpired(disk, t)) {
|
|
677
|
+
mem = disk;
|
|
678
|
+
rec = disk;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (rejectedFp && rec && keyFingerprint(rec.token) !== rejectedFp && isUnexpired(rec, t)) {
|
|
682
|
+
return view(rec, { cached: true, replaced: true });
|
|
683
|
+
}
|
|
684
|
+
// No token because a 401 invalidated it: the next mint is a re-mint, whoever
|
|
685
|
+
// asks for it (a quota poll, the next helper run). Without this a plain read
|
|
686
|
+
// after invalidate() minted outside the budget.
|
|
687
|
+
const forced = !!opts.force || !!rejectedFp || (!rec && wasInvalidated());
|
|
688
|
+
if (!forced && rec && !needsRefresh(rec, t)) return view(rec, { cached: true });
|
|
689
|
+
if (!inflight) {
|
|
690
|
+
inflight = exchange({ forced }).finally(() => { inflight = null; });
|
|
691
|
+
}
|
|
692
|
+
const r = await inflight;
|
|
693
|
+
if (r.ok) return r;
|
|
694
|
+
const still = current();
|
|
695
|
+
if (!forced && still && isUnexpired(still, now())) {
|
|
696
|
+
log("warn", `cohort-llm token refresh failed (${r.error.code}); serving the current token until it expires at ${still.expiresAt}`);
|
|
697
|
+
return view(still, { stale: true, refreshError: r.error });
|
|
698
|
+
}
|
|
699
|
+
log("error", `cohort-llm token unavailable: ${r.error.code} — ${r.error.message}`);
|
|
700
|
+
return r;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Synchronous read of a token that is before its refresh point (or merely
|
|
705
|
+
* unexpired, with `allowStale`). null when there is nothing usable.
|
|
706
|
+
* @param {{allowStale?:boolean}} [opts]
|
|
707
|
+
*/
|
|
708
|
+
function peek(opts = {}) {
|
|
709
|
+
const rec = current();
|
|
710
|
+
const t = now();
|
|
711
|
+
if (!rec) return null;
|
|
712
|
+
if (!needsRefresh(rec, t)) return view(rec, { cached: true });
|
|
713
|
+
if (opts.allowStale && isUnexpired(rec, t)) return view(rec, { stale: true });
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Drop the cached token (after a 401 from the gateway). With `rejectedToken`,
|
|
719
|
+
* only when the cache still holds that token: a replacement another process
|
|
720
|
+
* already minted is kept. The re-mint history survives either way.
|
|
721
|
+
* @param {string} [rejectedToken]
|
|
722
|
+
*/
|
|
723
|
+
function invalidate(rejectedToken) {
|
|
724
|
+
if (nonEmpty(rejectedToken)) {
|
|
725
|
+
if (mem && mem.token === rejectedToken) mem = null;
|
|
726
|
+
if (mem) return; // this process already holds a replacement
|
|
727
|
+
} else {
|
|
728
|
+
mem = null;
|
|
729
|
+
}
|
|
730
|
+
let replaced = false;
|
|
731
|
+
withRecord((disk) => {
|
|
732
|
+
const m = mineOf(disk);
|
|
733
|
+
if (nonEmpty(rejectedToken) && m && nonEmpty(m.token) && m.token !== rejectedToken) {
|
|
734
|
+
replaced = true; // another process wrote one
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
hist = mergedHist(disk, now());
|
|
738
|
+
// The marker makes the next mint on this seat a forced one, from any process.
|
|
739
|
+
return { invalidatedAt: new Date(now()).toISOString(), keyFp, baseUrl, ...histFields(hist) };
|
|
740
|
+
});
|
|
741
|
+
if (!replaced) invalidatedHere = true;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return { getToken, peek, invalidate, baseUrl, keyFp, statePath };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
// Edge: the seat's own manager (config + env resolved once per process)
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
|
|
751
|
+
/** Parse KEY=value lines (quotes stripped). Never throws. */
|
|
752
|
+
function parseDotEnvFile(path) {
|
|
753
|
+
const out = {};
|
|
754
|
+
let body = "";
|
|
755
|
+
try { body = existsSync(path) ? readFileSync(path, "utf-8") : ""; } catch { return out; }
|
|
756
|
+
for (const raw of body.split("\n")) {
|
|
757
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(raw);
|
|
758
|
+
if (!m) continue;
|
|
759
|
+
const v = m[2].replace(/^(['"])(.*)\1$/, "$2");
|
|
760
|
+
if (v) out[m[1]] = v;
|
|
761
|
+
}
|
|
762
|
+
return out;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const _managers = new Map();
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* The token manager for a seat, memoised per (state path, key, base URL).
|
|
769
|
+
* @param {object} [o]
|
|
770
|
+
* @param {string} [o.agentRoot]
|
|
771
|
+
* @param {object} [o.cfg] org config (default: config/org.yaml)
|
|
772
|
+
* @param {object} [o.env]
|
|
773
|
+
* @param {boolean} [o.readDotEnv=false] also read <agentRoot>/.env for the key
|
|
774
|
+
* @param {typeof fetch} [o.fetchImpl]
|
|
775
|
+
* @param {() => number} [o.now]
|
|
776
|
+
* @param {(level:string, msg:string) => void} [o.log]
|
|
777
|
+
*/
|
|
778
|
+
export function seatTokenManager(o = {}) {
|
|
779
|
+
const agentRoot = resolve(o.agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd());
|
|
780
|
+
const env = o.env || process.env;
|
|
781
|
+
const cfg = o.cfg !== undefined ? o.cfg : loadOrgConfig(agentRoot);
|
|
782
|
+
const dotEnv = o.readDotEnv ? parseDotEnvFile(join(agentRoot, ".env")) : undefined;
|
|
783
|
+
const baseUrl = resolveLlmBaseUrl({ cfg, env });
|
|
784
|
+
const apiKey = seatApiKey({ cfg, env, dotEnv });
|
|
785
|
+
const statePath = tokenStatePath(agentRoot);
|
|
786
|
+
const key = `${statePath}|${keyFingerprint(apiKey)}|${baseUrl}`;
|
|
787
|
+
if (!o.fetchImpl && !o.now && _managers.has(key)) return _managers.get(key);
|
|
788
|
+
const m = createTokenManager({ baseUrl, apiKey, statePath, fetchImpl: o.fetchImpl, now: o.now, log: o.log, sleep: o.sleep });
|
|
789
|
+
if (!o.fetchImpl && !o.now) _managers.set(key, m);
|
|
790
|
+
return m;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Can this seat authenticate to cohort-llm at all? True when it holds an
|
|
795
|
+
* OrgApiKey (config or env) or an unexpired cached token. Never throws.
|
|
796
|
+
*
|
|
797
|
+
* `helperReadable: true` is the router's question for a Path B retarget: can
|
|
798
|
+
* the apiKeyHelper — a grandchild of the scrubbed claude child, which never
|
|
799
|
+
* sees a `*_API_KEY` from the daemon's process env — mint a token? Only a key
|
|
800
|
+
* in config/org.yaml or <agentRoot>/.env, or a live cached token, counts. An
|
|
801
|
+
* env-only key would route the seat to the gateway with a helper that exits 1.
|
|
802
|
+
* @param {{agentRoot?:string, env?:object, cfg?:object, dotEnv?:object, now?:number, helperReadable?:boolean}} [o]
|
|
803
|
+
*/
|
|
804
|
+
export function hasSeatCredential(o = {}) {
|
|
805
|
+
try {
|
|
806
|
+
const env = o.env || process.env;
|
|
807
|
+
const agentRoot = o.agentRoot || env.AGENT_ROOT || env.AGENT_DIR;
|
|
808
|
+
if (!o.helperReadable && seatApiKey({ env })) return true;
|
|
809
|
+
if (!agentRoot) return false;
|
|
810
|
+
const cfg = o.cfg !== undefined ? o.cfg : loadOrgConfig(agentRoot);
|
|
811
|
+
if (seatApiKey({ cfg, env: {} })) return true;
|
|
812
|
+
if (o.helperReadable) {
|
|
813
|
+
const dotEnv = o.dotEnv !== undefined ? o.dotEnv : parseDotEnvFile(join(resolve(agentRoot), ".env"));
|
|
814
|
+
if (seatApiKey({ env: {}, dotEnv })) return true;
|
|
815
|
+
}
|
|
816
|
+
return isUnexpired(readRecord(tokenStatePath(agentRoot)), Number.isFinite(o.now) ? o.now : Date.now());
|
|
817
|
+
} catch {
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const _clients = new Map();
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* The seat's cohort-llm clients — its token manager and a quota client that
|
|
826
|
+
* authenticates with that token — memoised per seat so every gate in one
|
|
827
|
+
* process shares one quota cache.
|
|
828
|
+
* @param {object} [o] as seatTokenManager, plus ttlMs
|
|
829
|
+
* @returns {{token: ReturnType<typeof createTokenManager>, quota: ReturnType<typeof createQuotaClient>}}
|
|
830
|
+
*/
|
|
831
|
+
export function seatCohortClients(o = {}) {
|
|
832
|
+
const agentRoot = resolve(o.agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd());
|
|
833
|
+
const token = seatTokenManager({ ...o, agentRoot });
|
|
834
|
+
const injected = !!(o.fetchImpl || o.now);
|
|
835
|
+
const key = `${token.statePath}|${token.keyFp}|${token.baseUrl}`;
|
|
836
|
+
if (!injected && _clients.has(key)) return _clients.get(key);
|
|
837
|
+
const quota = createQuotaClient({
|
|
838
|
+
baseUrl: token.baseUrl,
|
|
839
|
+
getToken: (opts) => token.getToken(opts),
|
|
840
|
+
invalidateToken: (rejected) => token.invalidate(rejected),
|
|
841
|
+
fetchImpl: o.fetchImpl,
|
|
842
|
+
now: o.now,
|
|
843
|
+
ttlMs: o.ttlMs,
|
|
844
|
+
statePath: quotaStatePath(agentRoot),
|
|
845
|
+
log: o.log,
|
|
846
|
+
});
|
|
847
|
+
const pair = { token, quota };
|
|
848
|
+
if (!injected) _clients.set(key, pair);
|
|
849
|
+
return pair;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** Test seam: forget every memoised seat manager and client. */
|
|
853
|
+
export function _resetTokenManagersForTests() {
|
|
854
|
+
_managers.clear();
|
|
855
|
+
_clients.clear();
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export default {
|
|
859
|
+
DEFAULT_LLM_BASE_URL,
|
|
860
|
+
resolveLlmBaseUrl,
|
|
861
|
+
cohortEndpoints,
|
|
862
|
+
seatApiKey,
|
|
863
|
+
tokenStatePath,
|
|
864
|
+
keyFingerprint,
|
|
865
|
+
apiKeyHelperCommand,
|
|
866
|
+
redactSecrets,
|
|
867
|
+
needsRefresh,
|
|
868
|
+
isUnexpired,
|
|
869
|
+
classifyExchangeFailure,
|
|
870
|
+
remintBudget,
|
|
871
|
+
exchangeRetryDelay,
|
|
872
|
+
parseRetryAfterMs,
|
|
873
|
+
DEFAULT_REMINT_CAPS,
|
|
874
|
+
DEFAULT_EXCHANGE_RETRY,
|
|
875
|
+
createTokenManager,
|
|
876
|
+
seatTokenManager,
|
|
877
|
+
seatCohortClients,
|
|
878
|
+
hasSeatCredential,
|
|
879
|
+
};
|