@opengsd/gsd-pi 1.4.0 → 1.5.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 +11 -11
- package/dist/onboarding.js +11 -0
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/GSD-WORKFLOW.md +20 -25
- package/dist/resources/extensions/claude-code-cli/stream-adapter.js +118 -53
- package/dist/resources/extensions/cursor-cli/index.js +16 -0
- package/dist/resources/extensions/cursor-cli/models.js +63 -0
- package/dist/resources/extensions/cursor-cli/package.json +11 -0
- package/dist/resources/extensions/cursor-cli/readiness.js +128 -0
- package/dist/resources/extensions/cursor-cli/stream-adapter.js +353 -0
- package/dist/resources/extensions/gsd/activity-log.js +4 -1
- package/dist/resources/extensions/gsd/artifact-verification.js +25 -9
- package/dist/resources/extensions/gsd/auto/closeout.js +45 -42
- package/dist/resources/extensions/gsd/auto/finalize.js +9 -0
- package/dist/resources/extensions/gsd/auto/orchestrator.js +1 -1
- package/dist/resources/extensions/gsd/auto/run-unit.js +5 -2
- package/dist/resources/extensions/gsd/auto/schedule-wakeup.js +3 -0
- package/dist/resources/extensions/gsd/auto-dispatch.js +15 -5
- package/dist/resources/extensions/gsd/auto-model-selection.js +36 -18
- package/dist/resources/extensions/gsd/auto-prompts.js +76 -2
- package/dist/resources/extensions/gsd/auto-start.js +4 -3
- package/dist/resources/extensions/gsd/auto-timeout-recovery.js +4 -0
- package/dist/resources/extensions/gsd/auto-worktree.js +38 -11
- package/dist/resources/extensions/gsd/auto.js +23 -3
- package/dist/resources/extensions/gsd/bootstrap/db-tools.js +3 -0
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +114 -5
- package/dist/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.js +58 -9
- package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +10 -2
- package/dist/resources/extensions/gsd/bootstrap/write-gate.js +14 -1
- package/dist/resources/extensions/gsd/codebase-generator.js +157 -19
- package/dist/resources/extensions/gsd/commands/handlers/core.js +40 -4
- package/dist/resources/extensions/gsd/commands-prefs-wizard.js +153 -1
- package/dist/resources/extensions/gsd/commands-worktree.js +11 -2
- package/dist/resources/extensions/gsd/db/queries.js +39 -0
- package/dist/resources/extensions/gsd/discussion-handoff.js +8 -3
- package/dist/resources/extensions/gsd/docs/preferences-reference.md +8 -2
- package/dist/resources/extensions/gsd/doctor-engine-checks.js +206 -14
- package/dist/resources/extensions/gsd/doctor-providers.js +38 -10
- package/dist/resources/extensions/gsd/doctor.js +93 -3
- package/dist/resources/extensions/gsd/exec-sandbox.js +6 -2
- package/dist/resources/extensions/gsd/flat-phase-migration.js +74 -23
- package/dist/resources/extensions/gsd/git-service.js +14 -18
- package/dist/resources/extensions/gsd/gsd-db.js +52 -0
- package/dist/resources/extensions/gsd/guidance.js +1 -0
- package/dist/resources/extensions/gsd/guided-flow.js +40 -2
- package/dist/resources/extensions/gsd/key-manager.js +1 -0
- package/dist/resources/extensions/gsd/markdown-renderer.js +25 -8
- package/dist/resources/extensions/gsd/migrate-external.js +24 -7
- package/dist/resources/extensions/gsd/milestone-merge-transaction.js +3 -3
- package/dist/resources/extensions/gsd/preferences-diagnostics.js +24 -1
- package/dist/resources/extensions/gsd/preferences-models.js +6 -4
- package/dist/resources/extensions/gsd/preferences-validation.js +25 -2
- package/dist/resources/extensions/gsd/preferences.js +29 -2
- package/dist/resources/extensions/gsd/prompts/plan-slice.md +4 -2
- package/dist/resources/extensions/gsd/redact-secrets.js +37 -0
- package/dist/resources/extensions/gsd/repo-identity.js +16 -0
- package/dist/resources/extensions/gsd/repository-registry.js +6 -1
- package/dist/resources/extensions/gsd/root-write-leak-guard.js +4 -2
- package/dist/resources/extensions/gsd/safe-fs.js +23 -2
- package/dist/resources/extensions/gsd/skills/gsd-headless/SKILL.md +7 -9
- package/dist/resources/extensions/gsd/skills/gsd-headless/references/commands.md +1 -0
- package/dist/resources/extensions/gsd/tools/plan-task.js +76 -8
- package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +40 -0
- package/dist/resources/extensions/gsd/unit-context-manifest.js +5 -4
- package/dist/resources/extensions/gsd/unit-registry.js +27 -2
- package/dist/resources/extensions/gsd/unit-runtime.js +21 -0
- package/dist/resources/extensions/gsd/workspace.js +26 -8
- package/dist/resources/extensions/gsd/worktree-lifecycle.js +67 -5
- package/dist/resources/extensions/gsd/worktree-manager.js +97 -4
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +7 -7
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/api/update/route.js +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +7 -7
- package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/node_modules/@gsd/native/package.json +1 -1
- package/dist/web/standalone/node_modules/node-pty/build/Makefile +2 -2
- package/dist/web/standalone/node_modules/node-pty/build/pty.target.mk +14 -14
- package/dist/wizard.d.ts +8 -0
- package/dist/wizard.js +8 -1
- package/integrations/hermes/CONTEXT.md +47 -0
- package/integrations/hermes/README.md +21 -0
- package/integrations/hermes/__init__.py +31 -0
- package/integrations/hermes/docs/issue-1162-grilling.md +197 -0
- package/integrations/hermes/docs/setup.md +4 -0
- package/integrations/hermes/open_gsd_hermes/commands.py +141 -0
- package/integrations/hermes/open_gsd_hermes/config.py +2 -0
- package/integrations/hermes/open_gsd_hermes/gsd_client.py +594 -31
- package/integrations/hermes/open_gsd_hermes/notifications.py +10 -1
- package/integrations/hermes/plugin.yaml +4 -0
- package/integrations/hermes/tests/test_config.py +6 -0
- package/integrations/hermes/tests/test_directory_loader.py +37 -0
- package/integrations/hermes/tests/test_gsd_client_cache.py +3 -0
- package/integrations/hermes/tests/test_gsd_client_execute.py +35 -0
- package/integrations/hermes/tests/test_gsd_client_process_cwd.py +22 -0
- package/integrations/hermes/tests/test_gsd_client_timeout.py +98 -0
- package/integrations/hermes/tests/test_milestone_client.py +447 -0
- package/integrations/hermes/tests/test_new_milestone_command.py +313 -0
- package/integrations/hermes/tests/test_supervisor_fsm.py +40 -0
- package/package.json +8 -8
- package/packages/cloud-mcp-gateway/dist/auth-store.d.ts +1 -0
- package/packages/cloud-mcp-gateway/dist/auth-store.d.ts.map +1 -1
- package/packages/cloud-mcp-gateway/dist/auth-store.js +21 -1
- package/packages/cloud-mcp-gateway/dist/auth-store.js.map +1 -1
- package/packages/cloud-mcp-gateway/dist/auth-store.test.js +34 -0
- package/packages/cloud-mcp-gateway/dist/auth-store.test.js.map +1 -1
- package/packages/cloud-mcp-gateway/package.json +2 -2
- package/packages/contracts/package.json +1 -1
- package/packages/daemon/dist/cloud-runtime.d.ts +2 -0
- package/packages/daemon/dist/cloud-runtime.d.ts.map +1 -1
- package/packages/daemon/dist/cloud-runtime.js +25 -1
- package/packages/daemon/dist/cloud-runtime.js.map +1 -1
- package/packages/daemon/dist/cloud-runtime.test.js +51 -0
- package/packages/daemon/dist/cloud-runtime.test.js.map +1 -1
- package/packages/daemon/dist/message-batcher.d.ts +7 -0
- package/packages/daemon/dist/message-batcher.d.ts.map +1 -1
- package/packages/daemon/dist/message-batcher.js +13 -0
- package/packages/daemon/dist/message-batcher.js.map +1 -1
- package/packages/daemon/dist/message-batcher.test.js +48 -0
- package/packages/daemon/dist/message-batcher.test.js.map +1 -1
- package/packages/daemon/dist/session-manager.js +1 -1
- package/packages/daemon/dist/session-manager.js.map +1 -1
- package/packages/daemon/package.json +5 -5
- package/packages/gsd-agent-core/dist/compaction/compaction.d.ts +13 -1
- package/packages/gsd-agent-core/dist/compaction/compaction.d.ts.map +1 -1
- package/packages/gsd-agent-core/dist/compaction/compaction.js +15 -1
- package/packages/gsd-agent-core/dist/compaction/compaction.js.map +1 -1
- package/packages/gsd-agent-core/dist/session/agent-session-navigation.d.ts.map +1 -1
- package/packages/gsd-agent-core/dist/session/agent-session-navigation.js +4 -1
- package/packages/gsd-agent-core/dist/session/agent-session-navigation.js.map +1 -1
- package/packages/gsd-agent-core/package.json +5 -5
- package/packages/gsd-agent-modes/dist/modes/interactive/controllers/chat-segment-walker.d.ts.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/controllers/chat-segment-walker.js +1 -0
- package/packages/gsd-agent-modes/dist/modes/interactive/controllers/chat-segment-walker.js.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js +1 -1
- package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js.map +1 -1
- package/packages/gsd-agent-modes/package.json +7 -7
- package/packages/mcp-server/dist/server.d.ts +18 -5
- package/packages/mcp-server/dist/server.d.ts.map +1 -1
- package/packages/mcp-server/dist/server.js +46 -5
- package/packages/mcp-server/dist/server.js.map +1 -1
- package/packages/mcp-server/dist/session-manager.js +1 -1
- package/packages/mcp-server/dist/session-manager.js.map +1 -1
- package/packages/mcp-server/package.json +4 -4
- package/packages/native/package.json +1 -1
- package/packages/pi-agent-core/package.json +1 -1
- package/packages/pi-ai/dist/env-api-keys.d.ts.map +1 -1
- package/packages/pi-ai/dist/env-api-keys.js +3 -0
- package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
- package/packages/pi-ai/dist/types.d.ts +2 -2
- package/packages/pi-ai/dist/types.d.ts.map +1 -1
- package/packages/pi-ai/dist/types.js.map +1 -1
- package/packages/pi-ai/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts +1 -0
- package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager-types.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager.js +3 -0
- package/packages/pi-coding-agent/dist/core/session-manager.js.map +1 -1
- package/packages/pi-coding-agent/package.json +7 -7
- package/packages/pi-tui/package.json +2 -2
- package/packages/rpc-client/dist/rpc-client.js +1 -1
- package/packages/rpc-client/dist/rpc-client.js.map +1 -1
- package/packages/rpc-client/package.json +2 -2
- package/pkg/package.json +1 -1
- package/scripts/link-workspace-packages.cjs +14 -4
- package/src/resources/GSD-WORKFLOW.md +20 -25
- package/src/resources/extensions/claude-code-cli/stream-adapter.ts +130 -51
- package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +371 -39
- package/src/resources/extensions/cursor-cli/index.ts +18 -0
- package/src/resources/extensions/cursor-cli/models.ts +65 -0
- package/src/resources/extensions/cursor-cli/package.json +11 -0
- package/src/resources/extensions/cursor-cli/readiness.ts +143 -0
- package/src/resources/extensions/cursor-cli/stream-adapter.ts +440 -0
- package/src/resources/extensions/cursor-cli/tests/index.test.ts +54 -0
- package/src/resources/extensions/cursor-cli/tests/readiness.test.ts +29 -0
- package/src/resources/extensions/cursor-cli/tests/stream-adapter.test.ts +166 -0
- package/src/resources/extensions/gsd/activity-log.ts +4 -1
- package/src/resources/extensions/gsd/artifact-verification.ts +25 -9
- package/src/resources/extensions/gsd/auto/closeout.ts +54 -56
- package/src/resources/extensions/gsd/auto/finalize.ts +8 -0
- package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -0
- package/src/resources/extensions/gsd/auto/orchestrator.ts +1 -1
- package/src/resources/extensions/gsd/auto/run-unit.ts +5 -2
- package/src/resources/extensions/gsd/auto/schedule-wakeup.ts +8 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +17 -5
- package/src/resources/extensions/gsd/auto-model-selection.ts +49 -18
- package/src/resources/extensions/gsd/auto-prompts.ts +73 -2
- package/src/resources/extensions/gsd/auto-start.ts +4 -3
- package/src/resources/extensions/gsd/auto-timeout-recovery.ts +4 -0
- package/src/resources/extensions/gsd/auto-worktree.ts +50 -11
- package/src/resources/extensions/gsd/auto.ts +22 -3
- package/src/resources/extensions/gsd/bootstrap/db-tools.ts +3 -0
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +128 -5
- package/src/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.ts +80 -12
- package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +12 -2
- package/src/resources/extensions/gsd/bootstrap/write-gate.ts +23 -1
- package/src/resources/extensions/gsd/codebase-generator.ts +179 -18
- package/src/resources/extensions/gsd/commands/handlers/core.ts +39 -3
- package/src/resources/extensions/gsd/commands-prefs-wizard.ts +135 -1
- package/src/resources/extensions/gsd/commands-worktree.ts +13 -2
- package/src/resources/extensions/gsd/db/queries.ts +40 -0
- package/src/resources/extensions/gsd/discussion-handoff.ts +10 -2
- package/src/resources/extensions/gsd/docs/preferences-reference.md +8 -2
- package/src/resources/extensions/gsd/doctor-engine-checks.ts +269 -18
- package/src/resources/extensions/gsd/doctor-providers.ts +39 -10
- package/src/resources/extensions/gsd/doctor-types.ts +5 -1
- package/src/resources/extensions/gsd/doctor.ts +95 -3
- package/src/resources/extensions/gsd/exec-sandbox.ts +6 -2
- package/src/resources/extensions/gsd/flat-phase-migration.ts +98 -22
- package/src/resources/extensions/gsd/git-service.ts +14 -17
- package/src/resources/extensions/gsd/gsd-db.ts +72 -0
- package/src/resources/extensions/gsd/guidance.ts +2 -0
- package/src/resources/extensions/gsd/guided-flow.ts +45 -2
- package/src/resources/extensions/gsd/key-manager.ts +1 -0
- package/src/resources/extensions/gsd/markdown-renderer.ts +25 -7
- package/src/resources/extensions/gsd/migrate-external.ts +23 -6
- package/src/resources/extensions/gsd/milestone-merge-transaction.ts +4 -4
- package/src/resources/extensions/gsd/model-router.ts +4 -5
- package/src/resources/extensions/gsd/preferences-diagnostics.ts +24 -0
- package/src/resources/extensions/gsd/preferences-models.ts +6 -3
- package/src/resources/extensions/gsd/preferences-validation.ts +28 -2
- package/src/resources/extensions/gsd/preferences.ts +38 -4
- package/src/resources/extensions/gsd/prompts/plan-slice.md +4 -2
- package/src/resources/extensions/gsd/redact-secrets.ts +40 -0
- package/src/resources/extensions/gsd/repo-identity.ts +17 -0
- package/src/resources/extensions/gsd/repository-registry.ts +7 -1
- package/src/resources/extensions/gsd/root-write-leak-guard.ts +3 -2
- package/src/resources/extensions/gsd/safe-fs.ts +22 -2
- package/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md +7 -9
- package/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md +1 -0
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +131 -5
- package/src/resources/extensions/gsd/tests/auto-migrating-recovery.test.ts +18 -0
- package/src/resources/extensions/gsd/tests/auto-model-selection.test.ts +178 -0
- package/src/resources/extensions/gsd/tests/auto-phases-lifecycle.test.ts +43 -1
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +54 -0
- package/src/resources/extensions/gsd/tests/auto-remote-session-lock-cleanup.test.ts +41 -0
- package/src/resources/extensions/gsd/tests/auto-runtime-state.test.ts +41 -0
- package/src/resources/extensions/gsd/tests/auto-session-scope.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/auto-start-bootstrap-await-3420.test.ts +3 -0
- package/src/resources/extensions/gsd/tests/auto-timeout-recovery-harness-abort.test.ts +55 -0
- package/src/resources/extensions/gsd/tests/auto-warning-noise-regression.test.ts +8 -0
- package/src/resources/extensions/gsd/tests/batch-task-query.test.ts +85 -0
- package/src/resources/extensions/gsd/tests/check-auto-start-ready-guard.test.ts +29 -0
- package/src/resources/extensions/gsd/tests/codebase-generator.test.ts +365 -0
- package/src/resources/extensions/gsd/tests/complete-milestone-excerpt.test.ts +62 -1
- package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +138 -1
- package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +397 -5
- package/src/resources/extensions/gsd/tests/doctor-workspace.test.ts +207 -0
- package/src/resources/extensions/gsd/tests/flat-phase-migration.test.ts +38 -1
- package/src/resources/extensions/gsd/tests/flat-rate-routing-guard.test.ts +87 -19
- package/src/resources/extensions/gsd/tests/gate-1b-orphan-discrimination.test.ts +89 -0
- package/src/resources/extensions/gsd/tests/guided-flow-session-isolation.test.ts +9 -0
- package/src/resources/extensions/gsd/tests/integration/state-machine-edge-cases.test.ts +4 -2
- package/src/resources/extensions/gsd/tests/markdown-renderer.test.ts +122 -0
- package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts +116 -0
- package/src/resources/extensions/gsd/tests/milestone-merge-stash-restore.test.ts +152 -22
- package/src/resources/extensions/gsd/tests/milestone-merge-transaction.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/pending-autostart-scope.test.ts +2 -2
- package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +50 -6
- package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +17 -0
- package/src/resources/extensions/gsd/tests/plan-task.test.ts +264 -1
- package/src/resources/extensions/gsd/tests/preferences.test.ts +150 -0
- package/src/resources/extensions/gsd/tests/prefs-wizard-coverage.test.ts +142 -0
- package/src/resources/extensions/gsd/tests/recovery-verify-logs.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/redact-secrets.test.ts +56 -0
- package/src/resources/extensions/gsd/tests/register-hooks-flat-phase-migration.test.ts +73 -0
- package/src/resources/extensions/gsd/tests/register-hooks-loop-guard-auto.test.ts +275 -0
- package/src/resources/extensions/gsd/tests/repository-registry.test.ts +41 -2
- package/src/resources/extensions/gsd/tests/require-slice-discussion-dispatch.test.ts +55 -0
- package/src/resources/extensions/gsd/tests/root-write-leak-guard.test.ts +31 -9
- package/src/resources/extensions/gsd/tests/schedule-wakeup-tool.test.ts +167 -0
- package/src/resources/extensions/gsd/tests/stalled-tool-recovery.test.ts +49 -0
- package/src/resources/extensions/gsd/tests/state-corruption-2945.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/status-per-repo.test.ts +94 -0
- package/src/resources/extensions/gsd/tests/token-tool-gating.test.ts +35 -0
- package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +63 -0
- package/src/resources/extensions/gsd/tests/unit-context-manifest.test.ts +66 -18
- package/src/resources/extensions/gsd/tests/unit-registry.test.ts +56 -0
- package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +30 -0
- package/src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts +52 -0
- package/src/resources/extensions/gsd/tests/validator-scope-parity.test.ts +53 -1
- package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +208 -1
- package/src/resources/extensions/gsd/tests/workspace.test.ts +18 -4
- package/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/worktree-lifecycle.test.ts +2 -2
- package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +31 -1
- package/src/resources/extensions/gsd/tests/worktree-teardown-safety.test.ts +32 -0
- package/src/resources/extensions/gsd/tools/plan-task.ts +107 -9
- package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +58 -0
- package/src/resources/extensions/gsd/unit-context-manifest.ts +11 -5
- package/src/resources/extensions/gsd/unit-registry.ts +34 -2
- package/src/resources/extensions/gsd/unit-runtime.ts +42 -0
- package/src/resources/extensions/gsd/workspace.ts +42 -8
- package/src/resources/extensions/gsd/worktree-lifecycle.ts +135 -11
- package/src/resources/extensions/gsd/worktree-manager.ts +143 -5
- /package/dist/web/standalone/.next/static/{zQLcXm0Za8lexoXtgDpMG → bCg3aYrFN1DKZr2dqgE7L}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{zQLcXm0Za8lexoXtgDpMG → bCg3aYrFN1DKZr2dqgE7L}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Tests for /gsd new-milestone command routing and the cross-command guards.
|
|
2
|
+
|
|
3
|
+
Covers the issue #1162 design:
|
|
4
|
+
- _cmd_new_milestone: input parsing (bare text, --file, empty, --auto rejected),
|
|
5
|
+
co-run guard (reject if auto RUNNING/BLOCKED), spawn + ack, help text.
|
|
6
|
+
- _cmd_auto: symmetric co-run guard (reject if milestone run active).
|
|
7
|
+
- _cmd_cancel: routes to cancel_milestone() when a milestone subprocess is active.
|
|
8
|
+
- _cmd_reply: routes planning-blocker replies to client stdin when milestone
|
|
9
|
+
subprocess is active with a pending blocker.
|
|
10
|
+
|
|
11
|
+
Async handlers are driven with asyncio.run, matching the convention in
|
|
12
|
+
test_register.py.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from unittest.mock import MagicMock
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
from open_gsd_hermes.commands import GsdCommandRouter
|
|
24
|
+
from open_gsd_hermes.config import GsdConfig
|
|
25
|
+
from open_gsd_hermes.supervisor import SupervisorContext, SupervisorState
|
|
26
|
+
from open_gsd_hermes.types import BindingContext
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _make_router(
|
|
30
|
+
*,
|
|
31
|
+
project_dir: str | None = "/proj",
|
|
32
|
+
supervisor_state: SupervisorState = SupervisorState.IDLE,
|
|
33
|
+
milestone_active: bool = False,
|
|
34
|
+
milestone_blocker_id: str | None = None,
|
|
35
|
+
) -> tuple[GsdCommandRouter, MagicMock]:
|
|
36
|
+
"""Build a router with a MagicMock client + bind store for isolated testing."""
|
|
37
|
+
config = GsdConfig(default_project=project_dir)
|
|
38
|
+
client = MagicMock()
|
|
39
|
+
client.milestone_active.return_value = milestone_active
|
|
40
|
+
client.milestone_session_id.return_value = "S-1" if milestone_active else None
|
|
41
|
+
client.milestone_pending_blocker_id.return_value = milestone_blocker_id
|
|
42
|
+
bind_store = MagicMock()
|
|
43
|
+
supervisor = MagicMock()
|
|
44
|
+
notifications = MagicMock()
|
|
45
|
+
|
|
46
|
+
sup_ctx_holder: dict[str, SupervisorContext] = {
|
|
47
|
+
"ctx": SupervisorContext(
|
|
48
|
+
session_id=None,
|
|
49
|
+
project_dir=project_dir,
|
|
50
|
+
state=supervisor_state,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def get_session_key() -> str:
|
|
55
|
+
return "agent:main:cli:direct:local"
|
|
56
|
+
|
|
57
|
+
def get_binding_ctx() -> BindingContext:
|
|
58
|
+
return BindingContext(cwd=project_dir)
|
|
59
|
+
|
|
60
|
+
def get_supervisor_ctx() -> SupervisorContext:
|
|
61
|
+
return sup_ctx_holder["ctx"]
|
|
62
|
+
|
|
63
|
+
def set_supervisor_ctx(ctx: SupervisorContext) -> None:
|
|
64
|
+
sup_ctx_holder["ctx"] = ctx
|
|
65
|
+
|
|
66
|
+
def get_platform_channel() -> tuple[str | None, str | None]:
|
|
67
|
+
return "cli", "local"
|
|
68
|
+
|
|
69
|
+
router = GsdCommandRouter(
|
|
70
|
+
config,
|
|
71
|
+
client,
|
|
72
|
+
bind_store,
|
|
73
|
+
supervisor,
|
|
74
|
+
get_session_key,
|
|
75
|
+
get_binding_ctx,
|
|
76
|
+
get_supervisor_ctx,
|
|
77
|
+
set_supervisor_ctx,
|
|
78
|
+
get_platform_channel,
|
|
79
|
+
notifications,
|
|
80
|
+
)
|
|
81
|
+
return router, client
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.fixture
|
|
85
|
+
def project(tmp_path: Path) -> Path:
|
|
86
|
+
"""A directory that looks like a GSD project (.gsd/ present)."""
|
|
87
|
+
(tmp_path / ".gsd").mkdir()
|
|
88
|
+
return tmp_path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run(router: GsdCommandRouter, args: str) -> str:
|
|
92
|
+
return asyncio.run(router.handle(args))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# _cmd_new_milestone — input parsing & usage
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_new_milestone_bare_text_passes_context_text(project: Path) -> None:
|
|
101
|
+
router, client = _make_router(project_dir=str(project))
|
|
102
|
+
result = run(router, "new-milestone Build a REST API with auth")
|
|
103
|
+
client.create_milestone.assert_called_once()
|
|
104
|
+
kwargs = client.create_milestone.call_args.kwargs
|
|
105
|
+
assert kwargs.get("context_text") == "Build a REST API with auth"
|
|
106
|
+
assert kwargs.get("context_file") is None
|
|
107
|
+
assert "started" in result.lower() or "S-1" in result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_new_milestone_terminal_callback_clears_local_session_id(
|
|
111
|
+
project: Path,
|
|
112
|
+
) -> None:
|
|
113
|
+
router, client = _make_router(project_dir=str(project))
|
|
114
|
+
client.create_milestone.return_value = "milestone-4242"
|
|
115
|
+
|
|
116
|
+
run(router, "new-milestone Build a REST API with auth")
|
|
117
|
+
ctx = router._get_supervisor_ctx() # type: ignore[attr-defined]
|
|
118
|
+
ctx.pending_blocker_id = "blk-1"
|
|
119
|
+
assert ctx.session_id == "milestone-4242"
|
|
120
|
+
|
|
121
|
+
on_terminal = client.create_milestone.call_args.kwargs["on_terminal"]
|
|
122
|
+
on_terminal("complete")
|
|
123
|
+
|
|
124
|
+
ctx = router._get_supervisor_ctx() # type: ignore[attr-defined]
|
|
125
|
+
assert ctx.state == SupervisorState.COMPLETE
|
|
126
|
+
assert ctx.session_id is None
|
|
127
|
+
assert ctx.pending_blocker_id is None
|
|
128
|
+
assert ctx.notified_terminal is True
|
|
129
|
+
|
|
130
|
+
result = run(router, "reply stale response")
|
|
131
|
+
client.resolve_blocker.assert_not_called()
|
|
132
|
+
assert "no active" in result.lower()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_new_milestone_file_flag_passes_context_file(project: Path) -> None:
|
|
136
|
+
router, client = _make_router(project_dir=str(project))
|
|
137
|
+
spec = project / "spec.md"
|
|
138
|
+
spec.write_text("milestone spec", encoding="utf-8")
|
|
139
|
+
run(router, f"new-milestone --file {spec}")
|
|
140
|
+
kwargs = client.create_milestone.call_args.kwargs
|
|
141
|
+
assert kwargs.get("context_file") == str(spec)
|
|
142
|
+
assert kwargs.get("context_text") is None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_new_milestone_file_flag_resolves_relative_path_from_project(
|
|
146
|
+
project: Path,
|
|
147
|
+
) -> None:
|
|
148
|
+
router, client = _make_router(project_dir=str(project))
|
|
149
|
+
spec = project / "spec.md"
|
|
150
|
+
spec.write_text("milestone spec", encoding="utf-8")
|
|
151
|
+
run(router, "new-milestone --file spec.md")
|
|
152
|
+
kwargs = client.create_milestone.call_args.kwargs
|
|
153
|
+
assert kwargs.get("context_file") == str(spec)
|
|
154
|
+
assert kwargs.get("context_text") is None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_new_milestone_file_flag_rejects_missing_file(project: Path) -> None:
|
|
158
|
+
router, client = _make_router(project_dir=str(project))
|
|
159
|
+
result = run(router, "new-milestone --file missing.md")
|
|
160
|
+
assert "not found" in result.lower()
|
|
161
|
+
client.create_milestone.assert_not_called()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_new_milestone_empty_returns_usage(project: Path) -> None:
|
|
165
|
+
router, client = _make_router(project_dir=str(project))
|
|
166
|
+
result = run(router, "new-milestone")
|
|
167
|
+
assert "usage" in result.lower()
|
|
168
|
+
client.create_milestone.assert_not_called()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_new_milestone_rejects_auto_flag(project: Path) -> None:
|
|
172
|
+
"""--auto chaining is out of scope; execution stays with /gsd auto."""
|
|
173
|
+
router, client = _make_router(project_dir=str(project))
|
|
174
|
+
result = run(router, "new-milestone --auto some spec")
|
|
175
|
+
assert "auto" in result.lower()
|
|
176
|
+
client.create_milestone.assert_not_called()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Co-run guards (Q2 + Q9 symmetry)
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_new_milestone_rejects_when_auto_running(project: Path) -> None:
|
|
185
|
+
"""Co-run guard: refuse if an auto session is RUNNING or BLOCKED."""
|
|
186
|
+
router, client = _make_router(
|
|
187
|
+
project_dir=str(project),
|
|
188
|
+
supervisor_state=SupervisorState.RUNNING,
|
|
189
|
+
)
|
|
190
|
+
result = run(router, "new-milestone some spec")
|
|
191
|
+
assert "cancel" in result.lower() or "running" in result.lower()
|
|
192
|
+
client.create_milestone.assert_not_called()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_new_milestone_rejects_when_auto_blocked(project: Path) -> None:
|
|
196
|
+
router, client = _make_router(
|
|
197
|
+
project_dir=str(project),
|
|
198
|
+
supervisor_state=SupervisorState.BLOCKED,
|
|
199
|
+
)
|
|
200
|
+
result = run(router, "new-milestone some spec")
|
|
201
|
+
assert "cancel" in result.lower() or "running" in result.lower()
|
|
202
|
+
client.create_milestone.assert_not_called()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_new_milestone_prioritizes_milestone_guard_over_auto_state(
|
|
206
|
+
project: Path,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""A duplicate milestone command should report the milestone guard."""
|
|
209
|
+
router, client = _make_router(
|
|
210
|
+
project_dir=str(project),
|
|
211
|
+
supervisor_state=SupervisorState.RUNNING,
|
|
212
|
+
milestone_active=True,
|
|
213
|
+
)
|
|
214
|
+
result = run(router, "new-milestone some spec")
|
|
215
|
+
assert "milestone creation is in progress" in result.lower()
|
|
216
|
+
assert "auto is running" not in result.lower()
|
|
217
|
+
client.create_milestone.assert_not_called()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_new_milestone_allows_when_auto_complete(project: Path) -> None:
|
|
221
|
+
"""A terminal auto state does not block milestone creation."""
|
|
222
|
+
router, client = _make_router(
|
|
223
|
+
project_dir=str(project),
|
|
224
|
+
supervisor_state=SupervisorState.COMPLETE,
|
|
225
|
+
)
|
|
226
|
+
run(router, "new-milestone some spec")
|
|
227
|
+
client.create_milestone.assert_called_once()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_auto_rejects_when_milestone_active(project: Path) -> None:
|
|
231
|
+
"""Symmetric guard: /gsd auto refuses if a milestone run is in progress."""
|
|
232
|
+
router, client = _make_router(
|
|
233
|
+
project_dir=str(project),
|
|
234
|
+
milestone_active=True,
|
|
235
|
+
)
|
|
236
|
+
result = run(router, "auto")
|
|
237
|
+
assert "cancel" in result.lower() or "milestone" in result.lower()
|
|
238
|
+
client.execute.assert_not_called()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# _cmd_cancel — milestone branch
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_cancel_routes_to_cancel_milestone_when_milestone_active(
|
|
247
|
+
project: Path,
|
|
248
|
+
) -> None:
|
|
249
|
+
router, client = _make_router(
|
|
250
|
+
project_dir=str(project),
|
|
251
|
+
milestone_active=True,
|
|
252
|
+
)
|
|
253
|
+
run(router, "cancel")
|
|
254
|
+
client.cancel_milestone.assert_called_once()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_cancel_routes_to_mcp_cancel_when_only_auto_active(project: Path) -> None:
|
|
258
|
+
"""When no milestone proc is active, cancel falls back to the MCP path."""
|
|
259
|
+
router, client = _make_router(
|
|
260
|
+
project_dir=str(project),
|
|
261
|
+
supervisor_state=SupervisorState.RUNNING,
|
|
262
|
+
)
|
|
263
|
+
# Give the auto session an id so the MCP cancel-by-session path is taken
|
|
264
|
+
run(router, "cancel")
|
|
265
|
+
client.cancel_milestone.assert_not_called()
|
|
266
|
+
assert client.cancel.called or client.cancel_by_project.called
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# _cmd_reply — milestone-blocker branch
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_reply_routes_to_milestone_blocker_when_milestone_pending(
|
|
275
|
+
project: Path,
|
|
276
|
+
) -> None:
|
|
277
|
+
router, client = _make_router(
|
|
278
|
+
project_dir=str(project),
|
|
279
|
+
milestone_active=True,
|
|
280
|
+
milestone_blocker_id="blk-9",
|
|
281
|
+
)
|
|
282
|
+
result = run(router, "reply use option B")
|
|
283
|
+
client.respond_to_milestone_blocker.assert_called_once_with("use option B")
|
|
284
|
+
assert "sent" in result.lower()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_reply_does_not_fall_through_to_mcp_when_milestone_active_no_blocker(
|
|
288
|
+
project: Path,
|
|
289
|
+
) -> None:
|
|
290
|
+
"""Active milestone without a pending blocker must not hit MCP resolve_blocker."""
|
|
291
|
+
router, client = _make_router(
|
|
292
|
+
project_dir=str(project),
|
|
293
|
+
milestone_active=True,
|
|
294
|
+
milestone_blocker_id=None,
|
|
295
|
+
)
|
|
296
|
+
router._get_supervisor_ctx().session_id = "milestone-1" # type: ignore[attr-defined]
|
|
297
|
+
result = run(router, "reply some answer")
|
|
298
|
+
client.respond_to_milestone_blocker.assert_not_called()
|
|
299
|
+
client.resolve_blocker.assert_not_called()
|
|
300
|
+
assert "no pending blocker" in result.lower()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_reply_falls_back_to_mcp_when_no_milestone(project: Path) -> None:
|
|
304
|
+
"""No milestone proc → existing MCP resolve_blocker path (needs a session)."""
|
|
305
|
+
router, client = _make_router(
|
|
306
|
+
project_dir=str(project),
|
|
307
|
+
supervisor_state=SupervisorState.BLOCKED,
|
|
308
|
+
)
|
|
309
|
+
# set a session id so the existing reply path doesn't short-circuit
|
|
310
|
+
router._get_supervisor_ctx().session_id = "auto-1" # type: ignore[attr-defined]
|
|
311
|
+
run(router, "reply some answer")
|
|
312
|
+
client.respond_to_milestone_blocker.assert_not_called()
|
|
313
|
+
client.resolve_blocker.assert_called_once()
|
|
@@ -138,6 +138,7 @@ def test_auto_resets_session_specific_supervisor_context(tmp_path) -> None:
|
|
|
138
138
|
)
|
|
139
139
|
supervisor = MockSupervisor()
|
|
140
140
|
client = MagicMock()
|
|
141
|
+
client.milestone_active.return_value = False
|
|
141
142
|
client.execute.return_value = {"sessionId": "new-session"}
|
|
142
143
|
|
|
143
144
|
router = GsdCommandRouter(
|
|
@@ -179,6 +180,7 @@ def test_status_returns_friendly_message_when_mcp_progress_fails(tmp_path) -> No
|
|
|
179
180
|
def test_auto_returns_friendly_message_when_mcp_execute_fails(tmp_path) -> None:
|
|
180
181
|
supervisor = MockSupervisor()
|
|
181
182
|
client = MagicMock()
|
|
183
|
+
client.milestone_active.return_value = False
|
|
182
184
|
client.execute.side_effect = McpProtocolError("sidecar unavailable")
|
|
183
185
|
router, _project_dir, _supervisor, _ctx, _stored = build_router(
|
|
184
186
|
tmp_path, client, supervisor=supervisor
|
|
@@ -193,6 +195,7 @@ def test_auto_returns_friendly_message_when_mcp_execute_fails(tmp_path) -> None:
|
|
|
193
195
|
def test_auto_rejects_missing_session_id_in_mcp_response(tmp_path) -> None:
|
|
194
196
|
supervisor = MockSupervisor()
|
|
195
197
|
client = MagicMock()
|
|
198
|
+
client.milestone_active.return_value = False
|
|
196
199
|
client.execute.return_value = {}
|
|
197
200
|
router, _project_dir, _supervisor, ctx, _stored = build_router(
|
|
198
201
|
tmp_path, client, supervisor=supervisor
|
|
@@ -209,6 +212,7 @@ def test_auto_rejects_missing_session_id_in_mcp_response(tmp_path) -> None:
|
|
|
209
212
|
def test_cancel_stops_supervisor_when_mcp_cancel_fails(tmp_path) -> None:
|
|
210
213
|
supervisor = MockSupervisor()
|
|
211
214
|
client = MagicMock()
|
|
215
|
+
client.milestone_active.return_value = False
|
|
212
216
|
client.cancel_by_project.side_effect = McpProtocolError("sidecar unavailable")
|
|
213
217
|
router, _project_dir, _supervisor, ctx, stored = build_router(
|
|
214
218
|
tmp_path, client, supervisor=supervisor
|
|
@@ -226,6 +230,7 @@ def test_cancel_sends_terminal_notification_after_successful_mcp_cancel(tmp_path
|
|
|
226
230
|
supervisor = MockSupervisor()
|
|
227
231
|
notifications = MagicMock()
|
|
228
232
|
client = MagicMock()
|
|
233
|
+
client.milestone_active.return_value = False
|
|
229
234
|
ctx = SupervisorContext(
|
|
230
235
|
session_id="s1",
|
|
231
236
|
state=SupervisorState.RUNNING,
|
|
@@ -261,6 +266,7 @@ def test_cancel_prefers_supervisor_project_for_active_session(tmp_path) -> None:
|
|
|
261
266
|
(running_project / ".gsd").mkdir()
|
|
262
267
|
supervisor = MockSupervisor()
|
|
263
268
|
client = MagicMock()
|
|
269
|
+
client.milestone_active.return_value = False
|
|
264
270
|
ctx = SupervisorContext(
|
|
265
271
|
session_id="s1",
|
|
266
272
|
project_dir=str(running_project),
|
|
@@ -291,6 +297,7 @@ def test_cancel_prefers_supervisor_project_for_active_session(tmp_path) -> None:
|
|
|
291
297
|
def test_cancel_stops_supervisor_when_binding_resolution_fails(tmp_path) -> None:
|
|
292
298
|
supervisor = MockSupervisor()
|
|
293
299
|
client = MagicMock()
|
|
300
|
+
client.milestone_active.return_value = False
|
|
294
301
|
ctx = SupervisorContext(
|
|
295
302
|
session_id="s1",
|
|
296
303
|
project_dir=str(tmp_path / "missing"),
|
|
@@ -323,6 +330,7 @@ def test_cancel_stops_supervisor_when_binding_resolution_fails(tmp_path) -> None
|
|
|
323
330
|
|
|
324
331
|
def test_reply_returns_friendly_message_when_mcp_resolve_fails(tmp_path) -> None:
|
|
325
332
|
client = MagicMock()
|
|
333
|
+
client.milestone_active.return_value = False
|
|
326
334
|
client.resolve_blocker.side_effect = McpProtocolError("sidecar unavailable")
|
|
327
335
|
ctx = SupervisorContext(session_id="s1", project_dir="/tmp/project")
|
|
328
336
|
router, _project_dir, _supervisor, _ctx, _stored = build_router(
|
|
@@ -370,6 +378,38 @@ def test_supervisor_detects_blocker() -> None:
|
|
|
370
378
|
assert any("blocker" in t.lower() or "Approve" in t for t in sent)
|
|
371
379
|
|
|
372
380
|
|
|
381
|
+
def test_notify_blocker_prefers_title_over_generic_message() -> None:
|
|
382
|
+
sent: list[str] = []
|
|
383
|
+
|
|
384
|
+
def dispatch(_name: str, args: dict) -> None:
|
|
385
|
+
sent.append(args.get("text", ""))
|
|
386
|
+
|
|
387
|
+
notifications = NotificationService(
|
|
388
|
+
MagicMock(),
|
|
389
|
+
GsdConfig(),
|
|
390
|
+
lambda: DeliveryTarget("slack", "channel", "C123"),
|
|
391
|
+
dispatch=dispatch,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
notifications.notify_blocker(
|
|
395
|
+
SessionStatus(
|
|
396
|
+
status="blocked",
|
|
397
|
+
pending_blocker={
|
|
398
|
+
"type": "extension_ui_request",
|
|
399
|
+
"method": "select",
|
|
400
|
+
"id": "sel-1",
|
|
401
|
+
"title": "Choose milestone scope",
|
|
402
|
+
"message": "Action required",
|
|
403
|
+
"options": ["A", "B"],
|
|
404
|
+
},
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
assert len(sent) == 1
|
|
409
|
+
assert "GSD blocker: Choose milestone scope" in sent[0]
|
|
410
|
+
assert "GSD blocker: Action required" not in sent[0]
|
|
411
|
+
|
|
412
|
+
|
|
373
413
|
def test_supervisor_terminal_tick_updates_progress_before_stopping() -> None:
|
|
374
414
|
ctx = SupervisorContext(
|
|
375
415
|
session_id="s1",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengsd/gsd-pi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "GSD Pi local-first coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"baseline:refactor:phase0": "pnpm run baseline:refactor:gate && node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/derive-state-db.test.ts src/resources/extensions/gsd/tests/single-writer-invariant.test.ts src/resources/extensions/gsd/tests/auto-recovery.test.ts src/resources/extensions/gsd/tests/auto-worktree-registry.test.ts",
|
|
78
78
|
"legacy:cleanup:gate": "node scripts/legacy-cleanup-gate.mjs",
|
|
79
79
|
"legacy:cleanup:evidence": "node scripts/legacy-cleanup-evidence.mjs",
|
|
80
|
-
"test:unit:compiled": "node --import ./scripts/dist-test-resolve.mjs --experimental-test-isolation=process --test-reporter=./scripts/test-reporter-compact.mjs --test \"dist-test/src/tests/*.test.js\" \"dist-test/src/resources/extensions/gsd/tests/*.test.js\" \"dist-test/src/resources/extensions/gsd/tests/*.test.mjs\" \"dist-test/src/resources/extensions/shared/tests/*.test.js\" \"dist-test/src/resources/extensions/subagent/tests/*.test.js\" \"dist-test/src/resources/extensions/claude-code-cli/tests/*.test.js\" \"dist-test/src/resources/extensions/github-sync/tests/*.test.js\" \"dist-test/src/resources/extensions/universal-config/tests/*.test.js\" \"dist-test/src/resources/extensions/visual-brief/tests/*.test.js\" \"dist-test/src/resources/extensions/voice/tests/*.test.js\" \"dist-test/src/resources/extensions/mcp-client/tests/*.test.js\" \"dist-test/src/resources/extensions/remote-questions/tests/*.test.js\"",
|
|
80
|
+
"test:unit:compiled": "node --import ./scripts/dist-test-resolve.mjs --experimental-test-isolation=process --test-reporter=./scripts/test-reporter-compact.mjs --test \"dist-test/src/tests/*.test.js\" \"dist-test/src/resources/extensions/gsd/tests/*.test.js\" \"dist-test/src/resources/extensions/gsd/tests/*.test.mjs\" \"dist-test/src/resources/extensions/shared/tests/*.test.js\" \"dist-test/src/resources/extensions/subagent/tests/*.test.js\" \"dist-test/src/resources/extensions/claude-code-cli/tests/*.test.js\" \"dist-test/src/resources/extensions/cursor-cli/tests/*.test.js\" \"dist-test/src/resources/extensions/github-sync/tests/*.test.js\" \"dist-test/src/resources/extensions/universal-config/tests/*.test.js\" \"dist-test/src/resources/extensions/visual-brief/tests/*.test.js\" \"dist-test/src/resources/extensions/voice/tests/*.test.js\" \"dist-test/src/resources/extensions/mcp-client/tests/*.test.js\" \"dist-test/src/resources/extensions/remote-questions/tests/*.test.js\"",
|
|
81
81
|
"test:unit": "pnpm run test:compile && pnpm run test:unit:compiled",
|
|
82
82
|
"test:changed:src": "node scripts/verify-changed-src-tests.mjs",
|
|
83
83
|
"test:packages": "pnpm run test:compile && pnpm run test:packages:compiled",
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
"audit:test-gaps": "node scripts/audit-test-gaps.mjs",
|
|
124
124
|
"audit:test-matrix": "node scripts/audit-test-matrix.mjs",
|
|
125
125
|
"test:evaluation": "node scripts/run-test-evaluation.mjs",
|
|
126
|
-
"test:coverage:unit": "cross-env NODE_OPTIONS=--max-old-space-size=8192 c8 --reporter=lcovonly --report-dir=coverage/unit --temp-directory=coverage/.tmp-unit --exclude=\"**/*.test.*\" --exclude=\"**/tests/**\" --exclude=\"**/test/**\" --all node --import ./scripts/dist-test-resolve.mjs --experimental-test-isolation=process --test \"dist-test/src/tests/*.test.js\" \"dist-test/src/resources/extensions/gsd/tests/*.test.js\" \"dist-test/src/resources/extensions/gsd/tests/*.test.mjs\" \"dist-test/src/resources/extensions/shared/tests/*.test.js\" \"dist-test/src/resources/extensions/subagent/tests/*.test.js\" \"dist-test/src/resources/extensions/claude-code-cli/tests/*.test.js\" \"dist-test/src/resources/extensions/github-sync/tests/*.test.js\" \"dist-test/src/resources/extensions/universal-config/tests/*.test.js\" \"dist-test/src/resources/extensions/visual-brief/tests/*.test.js\" \"dist-test/src/resources/extensions/voice/tests/*.test.js\" \"dist-test/src/resources/extensions/mcp-client/tests/*.test.js\" \"dist-test/src/resources/extensions/remote-questions/tests/*.test.js\"",
|
|
126
|
+
"test:coverage:unit": "cross-env NODE_OPTIONS=--max-old-space-size=8192 c8 --reporter=lcovonly --report-dir=coverage/unit --temp-directory=coverage/.tmp-unit --exclude=\"**/*.test.*\" --exclude=\"**/tests/**\" --exclude=\"**/test/**\" --all node --import ./scripts/dist-test-resolve.mjs --experimental-test-isolation=process --test \"dist-test/src/tests/*.test.js\" \"dist-test/src/resources/extensions/gsd/tests/*.test.js\" \"dist-test/src/resources/extensions/gsd/tests/*.test.mjs\" \"dist-test/src/resources/extensions/shared/tests/*.test.js\" \"dist-test/src/resources/extensions/subagent/tests/*.test.js\" \"dist-test/src/resources/extensions/claude-code-cli/tests/*.test.js\" \"dist-test/src/resources/extensions/cursor-cli/tests/*.test.js\" \"dist-test/src/resources/extensions/github-sync/tests/*.test.js\" \"dist-test/src/resources/extensions/universal-config/tests/*.test.js\" \"dist-test/src/resources/extensions/visual-brief/tests/*.test.js\" \"dist-test/src/resources/extensions/voice/tests/*.test.js\" \"dist-test/src/resources/extensions/mcp-client/tests/*.test.js\" \"dist-test/src/resources/extensions/remote-questions/tests/*.test.js\"",
|
|
127
127
|
"test:coverage:integration": "pnpm exec playwright install chromium && cross-env NODE_OPTIONS=--max-old-space-size=8192 c8 --reporter=lcovonly --report-dir=coverage/integration --temp-directory=coverage/.tmp-integration --exclude=\"**/*.test.*\" --exclude=\"**/tests/**\" --all node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test --test-timeout=180000 \"src/tests/integration/*.test.ts\" \"src/resources/extensions/gsd/tests/integration/*.test.ts\" \"src/resources/extensions/async-jobs/*.test.ts\" \"src/resources/extensions/ollama/**/*.test.ts\" \"src/resources/extensions/ollama/*.test.ts\" \"src/resources/extensions/search-the-web/tests/*.test.ts\" \"src/resources/extensions/bg-shell/tests/*.test.ts\" \"src/resources/extensions/slash-commands/tests/*.test.ts\" \"src/resources/extensions/browser-tools/tests/*.test.mjs\"",
|
|
128
128
|
"test:coverage:packages": "node scripts/run-coverage-packages.mjs",
|
|
129
129
|
"test:coverage:merge": "node scripts/merge-coverage-reports.mjs",
|
|
@@ -210,11 +210,11 @@
|
|
|
210
210
|
},
|
|
211
211
|
"optionalDependencies": {
|
|
212
212
|
"@mariozechner/clipboard": "0.3.6",
|
|
213
|
-
"@opengsd/engine-darwin-arm64": "1.
|
|
214
|
-
"@opengsd/engine-darwin-x64": "1.
|
|
215
|
-
"@opengsd/engine-linux-arm64-gnu": "1.
|
|
216
|
-
"@opengsd/engine-linux-x64-gnu": "1.
|
|
217
|
-
"@opengsd/engine-win32-x64-msvc": "1.
|
|
213
|
+
"@opengsd/engine-darwin-arm64": "1.5.0",
|
|
214
|
+
"@opengsd/engine-darwin-x64": "1.5.0",
|
|
215
|
+
"@opengsd/engine-linux-arm64-gnu": "1.5.0",
|
|
216
|
+
"@opengsd/engine-linux-x64-gnu": "1.5.0",
|
|
217
|
+
"@opengsd/engine-win32-x64-msvc": "1.5.0",
|
|
218
218
|
"fsevents": "~2.3.3",
|
|
219
219
|
"koffi": "^2.9.0"
|
|
220
220
|
},
|
|
@@ -46,6 +46,7 @@ export declare class InMemoryAuthStore {
|
|
|
46
46
|
revokeDeviceToken(deviceToken: string): boolean;
|
|
47
47
|
snapshot(): AuthStoreSnapshot;
|
|
48
48
|
protected afterMutation(): void;
|
|
49
|
+
private sweepExpiredPairingCodes;
|
|
49
50
|
private loadSnapshot;
|
|
50
51
|
}
|
|
51
52
|
export declare class FileAuthStore extends InMemoryAuthStore {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-store.d.ts","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,CAAC,CAAC;IACX,UAAU,EAAE,KAAK,CAAC,eAAe,GAAG,gBAAgB,CAAC,CAAC;IACtD,YAAY,EAAE,KAAK,CAAC,iBAAiB,GAAG,gBAAgB,CAAC,CAAC;IAC1D,YAAY,EAAE,KAAK,CAAC,iBAAiB,GAAG,gBAAgB,CAAC,CAAC;CAC3D;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,iBAAiB;IAC5B,SAAS,CAAC,QAAQ,CAAC,UAAU,kDAAyD;IACtF,SAAS,CAAC,QAAQ,CAAC,YAAY,oDAA2D;IAC1F,SAAS,CAAC,QAAQ,CAAC,YAAY,oDAA2D;gBAE9E,aAAa,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,QAAQ,CAAC,EAAE,iBAAiB;IAK3F,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAQjD,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI;IAO1D,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,iBAAiB,GAAG,IAAI;IAOvE,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,SAAiB,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE;
|
|
1
|
+
{"version":3,"file":"auth-store.d.ts","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,CAAC,CAAC;IACX,UAAU,EAAE,KAAK,CAAC,eAAe,GAAG,gBAAgB,CAAC,CAAC;IACtD,YAAY,EAAE,KAAK,CAAC,iBAAiB,GAAG,gBAAgB,CAAC,CAAC;IAC1D,YAAY,EAAE,KAAK,CAAC,iBAAiB,GAAG,gBAAgB,CAAC,CAAC;CAC3D;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,iBAAiB;IAC5B,SAAS,CAAC,QAAQ,CAAC,UAAU,kDAAyD;IACtF,SAAS,CAAC,QAAQ,CAAC,YAAY,oDAA2D;IAC1F,SAAS,CAAC,QAAQ,CAAC,YAAY,oDAA2D;gBAE9E,aAAa,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,QAAQ,CAAC,EAAE,iBAAiB;IAK3F,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAQjD,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI;IAO1D,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,iBAAiB,GAAG,IAAI;IAOvE,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,SAAiB,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE;IAkB9F,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,gBAAgB;IAmBzE,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO;IAQ/C,QAAQ,IAAI,iBAAiB;IAS7B,SAAS,CAAC,aAAa,IAAI,IAAI;IAO/B,OAAO,CAAC,wBAAwB;IAMhC,OAAO,CAAC,YAAY;CAarB;AAED,qBAAa,aAAc,SAAQ,iBAAiB;IAClD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;gBAGhC,QAAQ,EAAE,MAAM,EAChB,aAAa,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE;cAQhC,aAAa,IAAI,IAAI;IAIxC,OAAO,CAAC,OAAO;CAMhB;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAgB5F;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,SAAkC,GAAG,gBAAgB,CAK/G"}
|
|
@@ -36,7 +36,17 @@ export class InMemoryAuthStore {
|
|
|
36
36
|
return record;
|
|
37
37
|
}
|
|
38
38
|
createPairingCode(userId, ttlMs = 10 * 60 * 1000) {
|
|
39
|
-
|
|
39
|
+
this.sweepExpiredPairingCodes();
|
|
40
|
+
// One live pairing code per user: a new code invalidates any prior un-redeemed
|
|
41
|
+
// one, so the guessable set never grows beyond a single code per user (and the
|
|
42
|
+
// per-exchange scrypt cost stays bounded).
|
|
43
|
+
for (const [hash, record] of this.pairingCodes) {
|
|
44
|
+
if (record.userId === userId)
|
|
45
|
+
this.pairingCodes.delete(hash);
|
|
46
|
+
}
|
|
47
|
+
// 64 bits of entropy (16 hex chars). Endpoint-level request rate limiting is the
|
|
48
|
+
// complementary defense and belongs at the HTTP layer, not here.
|
|
49
|
+
const code = randomBytes(8).toString("hex").toUpperCase();
|
|
40
50
|
const expiresAt = Date.now() + ttlMs;
|
|
41
51
|
const key = deriveSecretHash(code);
|
|
42
52
|
this.pairingCodes.set(key.secretHash, { ...key, userId, expiresAt });
|
|
@@ -44,6 +54,7 @@ export class InMemoryAuthStore {
|
|
|
44
54
|
return { code, expiresAt };
|
|
45
55
|
}
|
|
46
56
|
exchangePairingCode(code, runtimeName) {
|
|
57
|
+
this.sweepExpiredPairingCodes();
|
|
47
58
|
const normalized = code.trim().toUpperCase();
|
|
48
59
|
const codeEntry = findSecretEntry(this.pairingCodes, normalized);
|
|
49
60
|
if (!codeEntry || codeEntry[1].expiresAt < Date.now()) {
|
|
@@ -80,6 +91,15 @@ export class InMemoryAuthStore {
|
|
|
80
91
|
afterMutation() {
|
|
81
92
|
// Extension point for persistent stores.
|
|
82
93
|
}
|
|
94
|
+
// Drop expired pairing codes so a long-lived gateway does not accumulate codes
|
|
95
|
+
// that were generated but never redeemed. Best-effort: callers persist via their
|
|
96
|
+
// own afterMutation() after the create/exchange that triggered the sweep.
|
|
97
|
+
sweepExpiredPairingCodes(now = Date.now()) {
|
|
98
|
+
for (const [hash, record] of this.pairingCodes) {
|
|
99
|
+
if (record.expiresAt < now)
|
|
100
|
+
this.pairingCodes.delete(hash);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
83
103
|
loadSnapshot(snapshot) {
|
|
84
104
|
for (const record of snapshot.userTokens ?? []) {
|
|
85
105
|
this.userTokens.set(record.secretHash, record);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-store.js","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from \"node:crypto\";\nimport { mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\n\nexport interface UserTokenRecord {\n userId: string;\n revoked?: boolean;\n}\n\nexport interface DeviceTokenRecord {\n userId: string;\n runtimeId: string;\n runtimeName?: string;\n revoked?: boolean;\n}\n\nexport interface PairingCodeRecord {\n userId: string;\n expiresAt: number;\n}\n\nexport interface DeviceTokenIssue {\n userId: string;\n runtimeId: string;\n deviceToken: string;\n}\n\nexport interface AuthStoreSnapshot {\n version: 1;\n userTokens: Array<UserTokenRecord & SecretHashRecord>;\n deviceTokens: Array<DeviceTokenRecord & SecretHashRecord>;\n pairingCodes: Array<PairingCodeRecord & SecretHashRecord>;\n}\n\nexport interface SecretHashRecord {\n secretHash: string;\n secretSalt: string;\n}\n\nexport class InMemoryAuthStore {\n protected readonly userTokens = new Map<string, UserTokenRecord & SecretHashRecord>();\n protected readonly deviceTokens = new Map<string, DeviceTokenRecord & SecretHashRecord>();\n protected readonly pairingCodes = new Map<string, PairingCodeRecord & SecretHashRecord>();\n\n constructor(seedUserToken?: { token: string; userId: string }, snapshot?: AuthStoreSnapshot) {\n if (snapshot) this.loadSnapshot(snapshot);\n if (seedUserToken) this.addUserToken(seedUserToken.token, seedUserToken.userId);\n }\n\n addUserToken(token: string, userId: string): void {\n const existing = findSecretRecord(this.userTokens, token);\n if (existing?.userId === userId && !existing.revoked) return;\n const key = deriveSecretHash(token);\n this.userTokens.set(key.secretHash, { ...key, userId });\n this.afterMutation();\n }\n\n authenticateUser(token: string | undefined): string | null {\n if (!token) return null;\n const record = findSecretRecord(this.userTokens, token);\n if (!record || record.revoked) return null;\n return record.userId;\n }\n\n authenticateDevice(token: string | undefined): DeviceTokenRecord | null {\n if (!token) return null;\n const record = findSecretRecord(this.deviceTokens, token);\n if (!record || record.revoked) return null;\n return record;\n }\n\n createPairingCode(userId: string, ttlMs = 10 * 60 * 1000): { code: string; expiresAt: number } {\n const code = randomBytes(4).toString(\"hex\").toUpperCase();\n const expiresAt = Date.now() + ttlMs;\n const key = deriveSecretHash(code);\n this.pairingCodes.set(key.secretHash, { ...key, userId, expiresAt });\n this.afterMutation();\n return { code, expiresAt };\n }\n\n exchangePairingCode(code: string, runtimeName?: string): DeviceTokenIssue {\n const normalized = code.trim().toUpperCase();\n const codeEntry = findSecretEntry(this.pairingCodes, normalized);\n if (!codeEntry || codeEntry[1].expiresAt < Date.now()) {\n if (codeEntry) this.pairingCodes.delete(codeEntry[0]);\n this.afterMutation();\n throw new Error(\"Pairing code is invalid or expired\");\n }\n const [codeHash, record] = codeEntry;\n this.pairingCodes.delete(codeHash);\n const runtimeId = `rt_${randomUUID()}`;\n const deviceToken = `gsd_dev_${randomBytes(32).toString(\"hex\")}`;\n const key = deriveSecretHash(deviceToken);\n this.deviceTokens.set(key.secretHash, { ...key, userId: record.userId, runtimeId, runtimeName });\n this.afterMutation();\n return { userId: record.userId, runtimeId, deviceToken };\n }\n\n revokeDeviceToken(deviceToken: string): boolean {\n const record = findSecretRecord(this.deviceTokens, deviceToken);\n if (!record) return false;\n record.revoked = true;\n this.afterMutation();\n return true;\n }\n\n snapshot(): AuthStoreSnapshot {\n return {\n version: 1,\n userTokens: Array.from(this.userTokens.values()),\n deviceTokens: Array.from(this.deviceTokens.values()),\n pairingCodes: Array.from(this.pairingCodes.values()),\n };\n }\n\n protected afterMutation(): void {\n // Extension point for persistent stores.\n }\n\n private loadSnapshot(snapshot: AuthStoreSnapshot): void {\n for (const record of snapshot.userTokens ?? []) {\n this.userTokens.set(record.secretHash, record);\n }\n for (const record of snapshot.deviceTokens ?? []) {\n this.deviceTokens.set(record.secretHash, record);\n }\n for (const record of snapshot.pairingCodes ?? []) {\n if (record.expiresAt >= Date.now()) {\n this.pairingCodes.set(record.secretHash, record);\n }\n }\n }\n}\n\nexport class FileAuthStore extends InMemoryAuthStore {\n private readonly filePath: string;\n\n constructor(\n filePath: string,\n seedUserToken?: { token: string; userId: string },\n ) {\n super(undefined, readSnapshot(filePath));\n this.filePath = filePath;\n if (seedUserToken) this.addUserToken(seedUserToken.token, seedUserToken.userId);\n this.persist();\n }\n\n protected override afterMutation(): void {\n this.persist();\n }\n\n private persist(): void {\n mkdirSync(dirname(this.filePath), { recursive: true });\n const tmp = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;\n writeFileSync(tmp, `${JSON.stringify(this.snapshot(), null, 2)}\\n`, { encoding: \"utf8\", mode: 0o600 });\n renameSync(tmp, this.filePath);\n }\n}\n\nexport function extractBearerToken(header: string | string[] | undefined): string | undefined {\n const value = Array.isArray(header) ? header[0] : header;\n if (!value || value.length <= \"Bearer \".length) return undefined;\n if (value.slice(0, \"Bearer\".length).toLowerCase() !== \"bearer\") return undefined;\n\n const firstSeparator = value.charCodeAt(\"Bearer\".length);\n if (firstSeparator !== 0x20 && firstSeparator !== 0x09) return undefined;\n\n let tokenStart = \"Bearer\".length + 1;\n while (tokenStart < value.length) {\n const char = value.charCodeAt(tokenStart);\n if (char !== 0x20 && char !== 0x09) break;\n tokenStart += 1;\n }\n\n return tokenStart < value.length ? value.slice(tokenStart) : undefined;\n}\n\nexport function deriveSecretHash(secret: string, secretSalt = randomBytes(16).toString(\"hex\")): SecretHashRecord {\n return {\n secretHash: scryptSync(secret, secretSalt, 32).toString(\"hex\"),\n secretSalt,\n };\n}\n\nfunction findSecretRecord<T extends SecretHashRecord>(records: Map<string, T>, secret: string): T | undefined {\n return findSecretEntry(records, secret)?.[1];\n}\n\nfunction findSecretEntry<T extends SecretHashRecord>(records: Map<string, T>, secret: string): [string, T] | undefined {\n for (const entry of records) {\n if (secretMatches(entry[1], secret)) return entry;\n }\n return undefined;\n}\n\nfunction secretMatches(record: SecretHashRecord, secret: string): boolean {\n const candidate = Buffer.from(deriveSecretHash(secret, record.secretSalt).secretHash, \"hex\");\n const expected = Buffer.from(record.secretHash, \"hex\");\n return candidate.length === expected.length && timingSafeEqual(candidate, expected);\n}\n\nfunction readSnapshot(filePath: string): AuthStoreSnapshot | undefined {\n try {\n const parsed = JSON.parse(readFileSync(filePath, \"utf8\")) as Partial<AuthStoreSnapshot>;\n if (parsed.version !== 1) return undefined;\n return {\n version: 1,\n userTokens: Array.isArray(parsed.userTokens) ? parsed.userTokens as AuthStoreSnapshot[\"userTokens\"] : [],\n deviceTokens: Array.isArray(parsed.deviceTokens) ? parsed.deviceTokens as AuthStoreSnapshot[\"deviceTokens\"] : [],\n pairingCodes: Array.isArray(parsed.pairingCodes) ? parsed.pairingCodes as AuthStoreSnapshot[\"pairingCodes\"] : [],\n };\n } catch {\n return undefined;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"auth-store.js","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnF,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC7E,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAqCpC,MAAM,OAAO,iBAAiB;IACT,UAAU,GAAG,IAAI,GAAG,EAA8C,CAAC;IACnE,YAAY,GAAG,IAAI,GAAG,EAAgD,CAAC;IACvE,YAAY,GAAG,IAAI,GAAG,EAAgD,CAAC;IAE1F,YAAY,aAAiD,EAAE,QAA4B;QACzF,IAAI,QAAQ;YAAE,IAAI,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;QAC1C,IAAI,aAAa;YAAE,IAAI,CAAC,YAAY,CAAC,aAAa,CAAC,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAClF,CAAC;IAED,YAAY,CAAC,KAAa,EAAE,MAAc;QACxC,MAAM,QAAQ,GAAG,gBAAgB,CAAC,IAAI,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;QAC1D,IAAI,QAAQ,EAAE,MAAM,KAAK,MAAM,IAAI,CAAC,QAAQ,CAAC,OAAO;YAAE,OAAO;QAC7D,MAAM,GAAG,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAAC;QACpC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,UAAU,EAAE,EAAE,GAAG,GAAG,EAAE,MAAM,EAAE,CAAC,CAAC;QACxD,IAAI,CAAC,aAAa,EAAE,CAAC;IACvB,CAAC;IAED,gBAAgB,CAAC,KAAyB;QACxC,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAC;QACxB,MAAM,MAAM,GAAG,gBAAgB,CAAC,IAAI,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;QACxD,IAAI,CAAC,MAAM,IAAI,MAAM,CAAC,OAAO;YAAE,OAAO,IAAI,CAAC;QAC3C,OAAO,MAAM,CAAC,MAAM,CAAC;IACvB,CAAC;IAED,kBAAkB,CAAC,KAAyB;QAC1C,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAC;QACxB,MAAM,MAAM,GAAG,gBAAgB,CAAC,IAAI,CAAC,YAAY,EAAE,KAAK,CAAC,CAAC;QAC1D,IAAI,CAAC,MAAM,IAAI,MAAM,CAAC,OAAO;YAAE,OAAO,IAAI,CAAC;QAC3C,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,iBAAiB,CAAC,MAAc,EAAE,KAAK,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI;QACtD,IAAI,CAAC,wBAAwB,EAAE,CAAC;QAChC,+EAA+E;QAC/E,+EAA+E;QAC/E,2CAA2C;QAC3C,KAAK,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;YAC/C,IAAI,MAAM,CAAC,MAAM,KAAK,MAAM;gBAAE,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAC/D,CAAC;QACD,iFAAiF;QACjF,iEAAiE;QACjE,MAAM,IAAI,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,CAAC;QAC1D,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC;QACrC,MAAM,GAAG,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;QACnC,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,UAAU,EAAE,EAAE,GAAG,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;QACrE,IAAI,CAAC,aAAa,EAAE,CAAC;QACrB,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;IAC7B,CAAC;IAED,mBAAmB,CAAC,IAAY,EAAE,WAAoB;QACpD,IAAI,CAAC,wBAAwB,EAAE,CAAC;QAChC,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;QAC7C,MAAM,SAAS,GAAG,eAAe,CAAC,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;QACjE,IAAI,CAAC,SAAS,IAAI,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;YACtD,IAAI,SAAS;gBAAE,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;YACtD,IAAI,CAAC,aAAa,EAAE,CAAC;YACrB,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;QACxD,CAAC;QACD,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;QACrC,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QACnC,MAAM,SAAS,GAAG,MAAM,UAAU,EAAE,EAAE,CAAC;QACvC,MAAM,WAAW,GAAG,WAAW,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QACjE,MAAM,GAAG,GAAG,gBAAgB,CAAC,WAAW,CAAC,CAAC;QAC1C,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,UAAU,EAAE,EAAE,GAAG,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,WAAW,EAAE,CAAC,CAAC;QACjG,IAAI,CAAC,aAAa,EAAE,CAAC;QACrB,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,WAAW,EAAE,CAAC;IAC3D,CAAC;IAED,iBAAiB,CAAC,WAAmB;QACnC,MAAM,MAAM,GAAG,gBAAgB,CAAC,IAAI,CAAC,YAAY,EAAE,WAAW,CAAC,CAAC;QAChE,IAAI,CAAC,MAAM;YAAE,OAAO,KAAK,CAAC;QAC1B,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;QACtB,IAAI,CAAC,aAAa,EAAE,CAAC;QACrB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,QAAQ;QACN,OAAO;YACL,OAAO,EAAE,CAAC;YACV,UAAU,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC;YAChD,YAAY,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,MAAM,EAAE,CAAC;YACpD,YAAY,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,MAAM,EAAE,CAAC;SACrD,CAAC;IACJ,CAAC;IAES,aAAa;QACrB,yCAAyC;IAC3C,CAAC;IAED,+EAA+E;IAC/E,iFAAiF;IACjF,0EAA0E;IAClE,wBAAwB,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE;QAC/C,KAAK,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;YAC/C,IAAI,MAAM,CAAC,SAAS,GAAG,GAAG;gBAAE,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAC7D,CAAC;IACH,CAAC;IAEO,YAAY,CAAC,QAA2B;QAC9C,KAAK,MAAM,MAAM,IAAI,QAAQ,CAAC,UAAU,IAAI,EAAE,EAAE,CAAC;YAC/C,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;QACjD,CAAC;QACD,KAAK,MAAM,MAAM,IAAI,QAAQ,CAAC,YAAY,IAAI,EAAE,EAAE,CAAC;YACjD,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;QACnD,CAAC;QACD,KAAK,MAAM,MAAM,IAAI,QAAQ,CAAC,YAAY,IAAI,EAAE,EAAE,CAAC;YACjD,IAAI,MAAM,CAAC,SAAS,IAAI,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;gBACnC,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;YACnD,CAAC;QACH,CAAC;IACH,CAAC;CACF;AAED,MAAM,OAAO,aAAc,SAAQ,iBAAiB;IACjC,QAAQ,CAAS;IAElC,YACE,QAAgB,EAChB,aAAiD;QAEjD,KAAK,CAAC,SAAS,EAAE,YAAY,CAAC,QAAQ,CAAC,CAAC,CAAC;QACzC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,aAAa;YAAE,IAAI,CAAC,YAAY,CAAC,aAAa,CAAC,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;QAChF,IAAI,CAAC,OAAO,EAAE,CAAC;IACjB,CAAC;IAEkB,aAAa;QAC9B,IAAI,CAAC,OAAO,EAAE,CAAC;IACjB,CAAC;IAEO,OAAO;QACb,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACvD,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,QAAQ,IAAI,OAAO,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE,MAAM,CAAC;QAChE,aAAa,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QACvG,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;IACjC,CAAC;CACF;AAED,MAAM,UAAU,kBAAkB,CAAC,MAAqC;IACtE,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IACzD,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,IAAI,SAAS,CAAC,MAAM;QAAE,OAAO,SAAS,CAAC;IACjE,IAAI,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAC;IAEjF,MAAM,cAAc,GAAG,KAAK,CAAC,UAAU,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACzD,IAAI,cAAc,KAAK,IAAI,IAAI,cAAc,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAEzE,IAAI,UAAU,GAAG,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC;IACrC,OAAO,UAAU,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC;QACjC,MAAM,IAAI,GAAG,KAAK,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC;QAC1C,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI;YAAE,MAAM;QAC1C,UAAU,IAAI,CAAC,CAAC;IAClB,CAAC;IAED,OAAO,UAAU,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AACzE,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,MAAc,EAAE,UAAU,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC;IAC3F,OAAO;QACL,UAAU,EAAE,UAAU,CAAC,MAAM,EAAE,UAAU,EAAE,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC;QAC9D,UAAU;KACX,CAAC;AACJ,CAAC;AAED,SAAS,gBAAgB,CAA6B,OAAuB,EAAE,MAAc;IAC3F,OAAO,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AAC/C,CAAC;AAED,SAAS,eAAe,CAA6B,OAAuB,EAAE,MAAc;IAC1F,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,IAAI,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC;YAAE,OAAO,KAAK,CAAC;IACpD,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,SAAS,aAAa,CAAC,MAAwB,EAAE,MAAc;IAC7D,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;IAC7F,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;IACvD,OAAO,SAAS,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM,IAAI,eAAe,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;AACtF,CAAC;AAED,SAAS,YAAY,CAAC,QAAgB;IACpC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,QAAQ,EAAE,MAAM,CAAC,CAA+B,CAAC;QACxF,IAAI,MAAM,CAAC,OAAO,KAAK,CAAC;YAAE,OAAO,SAAS,CAAC;QAC3C,OAAO;YACL,OAAO,EAAE,CAAC;YACV,UAAU,EAAE,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,UAA6C,CAAC,CAAC,CAAC,EAAE;YACxG,YAAY,EAAE,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,YAAiD,CAAC,CAAC,CAAC,EAAE;YAChH,YAAY,EAAE,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,YAAiD,CAAC,CAAC,CAAC,EAAE;SACjH,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC","sourcesContent":["import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from \"node:crypto\";\nimport { mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\n\nexport interface UserTokenRecord {\n userId: string;\n revoked?: boolean;\n}\n\nexport interface DeviceTokenRecord {\n userId: string;\n runtimeId: string;\n runtimeName?: string;\n revoked?: boolean;\n}\n\nexport interface PairingCodeRecord {\n userId: string;\n expiresAt: number;\n}\n\nexport interface DeviceTokenIssue {\n userId: string;\n runtimeId: string;\n deviceToken: string;\n}\n\nexport interface AuthStoreSnapshot {\n version: 1;\n userTokens: Array<UserTokenRecord & SecretHashRecord>;\n deviceTokens: Array<DeviceTokenRecord & SecretHashRecord>;\n pairingCodes: Array<PairingCodeRecord & SecretHashRecord>;\n}\n\nexport interface SecretHashRecord {\n secretHash: string;\n secretSalt: string;\n}\n\nexport class InMemoryAuthStore {\n protected readonly userTokens = new Map<string, UserTokenRecord & SecretHashRecord>();\n protected readonly deviceTokens = new Map<string, DeviceTokenRecord & SecretHashRecord>();\n protected readonly pairingCodes = new Map<string, PairingCodeRecord & SecretHashRecord>();\n\n constructor(seedUserToken?: { token: string; userId: string }, snapshot?: AuthStoreSnapshot) {\n if (snapshot) this.loadSnapshot(snapshot);\n if (seedUserToken) this.addUserToken(seedUserToken.token, seedUserToken.userId);\n }\n\n addUserToken(token: string, userId: string): void {\n const existing = findSecretRecord(this.userTokens, token);\n if (existing?.userId === userId && !existing.revoked) return;\n const key = deriveSecretHash(token);\n this.userTokens.set(key.secretHash, { ...key, userId });\n this.afterMutation();\n }\n\n authenticateUser(token: string | undefined): string | null {\n if (!token) return null;\n const record = findSecretRecord(this.userTokens, token);\n if (!record || record.revoked) return null;\n return record.userId;\n }\n\n authenticateDevice(token: string | undefined): DeviceTokenRecord | null {\n if (!token) return null;\n const record = findSecretRecord(this.deviceTokens, token);\n if (!record || record.revoked) return null;\n return record;\n }\n\n createPairingCode(userId: string, ttlMs = 10 * 60 * 1000): { code: string; expiresAt: number } {\n this.sweepExpiredPairingCodes();\n // One live pairing code per user: a new code invalidates any prior un-redeemed\n // one, so the guessable set never grows beyond a single code per user (and the\n // per-exchange scrypt cost stays bounded).\n for (const [hash, record] of this.pairingCodes) {\n if (record.userId === userId) this.pairingCodes.delete(hash);\n }\n // 64 bits of entropy (16 hex chars). Endpoint-level request rate limiting is the\n // complementary defense and belongs at the HTTP layer, not here.\n const code = randomBytes(8).toString(\"hex\").toUpperCase();\n const expiresAt = Date.now() + ttlMs;\n const key = deriveSecretHash(code);\n this.pairingCodes.set(key.secretHash, { ...key, userId, expiresAt });\n this.afterMutation();\n return { code, expiresAt };\n }\n\n exchangePairingCode(code: string, runtimeName?: string): DeviceTokenIssue {\n this.sweepExpiredPairingCodes();\n const normalized = code.trim().toUpperCase();\n const codeEntry = findSecretEntry(this.pairingCodes, normalized);\n if (!codeEntry || codeEntry[1].expiresAt < Date.now()) {\n if (codeEntry) this.pairingCodes.delete(codeEntry[0]);\n this.afterMutation();\n throw new Error(\"Pairing code is invalid or expired\");\n }\n const [codeHash, record] = codeEntry;\n this.pairingCodes.delete(codeHash);\n const runtimeId = `rt_${randomUUID()}`;\n const deviceToken = `gsd_dev_${randomBytes(32).toString(\"hex\")}`;\n const key = deriveSecretHash(deviceToken);\n this.deviceTokens.set(key.secretHash, { ...key, userId: record.userId, runtimeId, runtimeName });\n this.afterMutation();\n return { userId: record.userId, runtimeId, deviceToken };\n }\n\n revokeDeviceToken(deviceToken: string): boolean {\n const record = findSecretRecord(this.deviceTokens, deviceToken);\n if (!record) return false;\n record.revoked = true;\n this.afterMutation();\n return true;\n }\n\n snapshot(): AuthStoreSnapshot {\n return {\n version: 1,\n userTokens: Array.from(this.userTokens.values()),\n deviceTokens: Array.from(this.deviceTokens.values()),\n pairingCodes: Array.from(this.pairingCodes.values()),\n };\n }\n\n protected afterMutation(): void {\n // Extension point for persistent stores.\n }\n\n // Drop expired pairing codes so a long-lived gateway does not accumulate codes\n // that were generated but never redeemed. Best-effort: callers persist via their\n // own afterMutation() after the create/exchange that triggered the sweep.\n private sweepExpiredPairingCodes(now = Date.now()): void {\n for (const [hash, record] of this.pairingCodes) {\n if (record.expiresAt < now) this.pairingCodes.delete(hash);\n }\n }\n\n private loadSnapshot(snapshot: AuthStoreSnapshot): void {\n for (const record of snapshot.userTokens ?? []) {\n this.userTokens.set(record.secretHash, record);\n }\n for (const record of snapshot.deviceTokens ?? []) {\n this.deviceTokens.set(record.secretHash, record);\n }\n for (const record of snapshot.pairingCodes ?? []) {\n if (record.expiresAt >= Date.now()) {\n this.pairingCodes.set(record.secretHash, record);\n }\n }\n }\n}\n\nexport class FileAuthStore extends InMemoryAuthStore {\n private readonly filePath: string;\n\n constructor(\n filePath: string,\n seedUserToken?: { token: string; userId: string },\n ) {\n super(undefined, readSnapshot(filePath));\n this.filePath = filePath;\n if (seedUserToken) this.addUserToken(seedUserToken.token, seedUserToken.userId);\n this.persist();\n }\n\n protected override afterMutation(): void {\n this.persist();\n }\n\n private persist(): void {\n mkdirSync(dirname(this.filePath), { recursive: true });\n const tmp = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;\n writeFileSync(tmp, `${JSON.stringify(this.snapshot(), null, 2)}\\n`, { encoding: \"utf8\", mode: 0o600 });\n renameSync(tmp, this.filePath);\n }\n}\n\nexport function extractBearerToken(header: string | string[] | undefined): string | undefined {\n const value = Array.isArray(header) ? header[0] : header;\n if (!value || value.length <= \"Bearer \".length) return undefined;\n if (value.slice(0, \"Bearer\".length).toLowerCase() !== \"bearer\") return undefined;\n\n const firstSeparator = value.charCodeAt(\"Bearer\".length);\n if (firstSeparator !== 0x20 && firstSeparator !== 0x09) return undefined;\n\n let tokenStart = \"Bearer\".length + 1;\n while (tokenStart < value.length) {\n const char = value.charCodeAt(tokenStart);\n if (char !== 0x20 && char !== 0x09) break;\n tokenStart += 1;\n }\n\n return tokenStart < value.length ? value.slice(tokenStart) : undefined;\n}\n\nexport function deriveSecretHash(secret: string, secretSalt = randomBytes(16).toString(\"hex\")): SecretHashRecord {\n return {\n secretHash: scryptSync(secret, secretSalt, 32).toString(\"hex\"),\n secretSalt,\n };\n}\n\nfunction findSecretRecord<T extends SecretHashRecord>(records: Map<string, T>, secret: string): T | undefined {\n return findSecretEntry(records, secret)?.[1];\n}\n\nfunction findSecretEntry<T extends SecretHashRecord>(records: Map<string, T>, secret: string): [string, T] | undefined {\n for (const entry of records) {\n if (secretMatches(entry[1], secret)) return entry;\n }\n return undefined;\n}\n\nfunction secretMatches(record: SecretHashRecord, secret: string): boolean {\n const candidate = Buffer.from(deriveSecretHash(secret, record.secretSalt).secretHash, \"hex\");\n const expected = Buffer.from(record.secretHash, \"hex\");\n return candidate.length === expected.length && timingSafeEqual(candidate, expected);\n}\n\nfunction readSnapshot(filePath: string): AuthStoreSnapshot | undefined {\n try {\n const parsed = JSON.parse(readFileSync(filePath, \"utf8\")) as Partial<AuthStoreSnapshot>;\n if (parsed.version !== 1) return undefined;\n return {\n version: 1,\n userTokens: Array.isArray(parsed.userTokens) ? parsed.userTokens as AuthStoreSnapshot[\"userTokens\"] : [],\n deviceTokens: Array.isArray(parsed.deviceTokens) ? parsed.deviceTokens as AuthStoreSnapshot[\"deviceTokens\"] : [],\n pairingCodes: Array.isArray(parsed.pairingCodes) ? parsed.pairingCodes as AuthStoreSnapshot[\"pairingCodes\"] : [],\n };\n } catch {\n return undefined;\n }\n}\n"]}
|
|
@@ -22,6 +22,40 @@ test("pairing code is one-time use", () => {
|
|
|
22
22
|
auth.exchangePairingCode(code);
|
|
23
23
|
assert.throws(() => auth.exchangePairingCode(code), /invalid or expired/);
|
|
24
24
|
});
|
|
25
|
+
test("pairing code has 64 bits of entropy (16 hex chars)", () => {
|
|
26
|
+
const auth = new InMemoryAuthStore({ token: "user-token", userId: "u1" });
|
|
27
|
+
const { code } = auth.createPairingCode("u1");
|
|
28
|
+
assert.match(code, /^[0-9A-F]{16}$/);
|
|
29
|
+
});
|
|
30
|
+
test("a new pairing code invalidates the user's prior un-redeemed code", () => {
|
|
31
|
+
const auth = new InMemoryAuthStore({ token: "user-token", userId: "u1" });
|
|
32
|
+
const { code: first } = auth.createPairingCode("u1");
|
|
33
|
+
const { code: second } = auth.createPairingCode("u1");
|
|
34
|
+
assert.notEqual(first, second);
|
|
35
|
+
assert.throws(() => auth.exchangePairingCode(first), /invalid or expired/);
|
|
36
|
+
// The newest code still works.
|
|
37
|
+
const issued = auth.exchangePairingCode(second);
|
|
38
|
+
assert.equal(typeof issued.deviceToken, "string");
|
|
39
|
+
});
|
|
40
|
+
test("codes for different users coexist", () => {
|
|
41
|
+
const auth = new InMemoryAuthStore({ token: "user-token", userId: "u1" });
|
|
42
|
+
auth.addUserToken("user-token-2", "u2");
|
|
43
|
+
const { code: c1 } = auth.createPairingCode("u1");
|
|
44
|
+
const { code: c2 } = auth.createPairingCode("u2");
|
|
45
|
+
assert.equal(auth.exchangePairingCode(c1).userId, "u1");
|
|
46
|
+
assert.equal(auth.exchangePairingCode(c2).userId, "u2");
|
|
47
|
+
});
|
|
48
|
+
test("expired pairing codes are swept on the next create/exchange", () => {
|
|
49
|
+
const auth = new InMemoryAuthStore({ token: "user-token", userId: "u1" });
|
|
50
|
+
// Create an already-expired code for u2 (negative ttl), then a fresh code for u1.
|
|
51
|
+
auth.addUserToken("user-token-2", "u2");
|
|
52
|
+
const { code: stale } = auth.createPairingCode("u2", -1);
|
|
53
|
+
auth.createPairingCode("u1"); // triggers the sweep
|
|
54
|
+
const snapshot = auth.snapshot();
|
|
55
|
+
// Only u1's live code remains; the expired u2 code was swept.
|
|
56
|
+
assert.equal(snapshot.pairingCodes.length, 1);
|
|
57
|
+
assert.throws(() => auth.exchangePairingCode(stale), /invalid or expired/);
|
|
58
|
+
});
|
|
25
59
|
test("extractBearerToken parses bearer auth header", () => {
|
|
26
60
|
assert.equal(extractBearerToken("Bearer abc"), "abc");
|
|
27
61
|
assert.equal(extractBearerToken("bearer abc"), "abc");
|