switchroom 0.7.15 → 0.10.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 +51 -59
- package/bin/run-hook.sh +27 -11
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +410 -133
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/switchroom.js +26937 -5601
- package/dist/host-control/main.js +12702 -0
- package/dist/vault/approvals/kernel-server.js +467 -184
- package/dist/vault/broker/server.js +1430 -724
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +7 -4
- package/profiles/_base/settings.json.hbs +20 -5
- package/profiles/_base/start.sh.hbs +16 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/_shared/telegram-style.md.hbs +20 -90
- package/profiles/_shared/vault-protocol.md.hbs +68 -0
- package/profiles/default/CLAUDE.md +50 -96
- package/profiles/default/CLAUDE.md.hbs +36 -6
- package/profiles/default/workspace/SOUL.md.hbs +12 -5
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +191 -0
- package/skills/switchroom-status/SKILL.md +27 -2
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/token-helpers/SKILL.md +24 -1
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/index.ts +7 -5
- package/telegram-plugin/analytics-posthog.ts +191 -0
- package/telegram-plugin/bridge/bridge.ts +69 -0
- package/telegram-plugin/bridge/ipc-client.ts +4 -1
- package/telegram-plugin/dist/bridge/bridge.js +194 -119
- package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
- package/telegram-plugin/dist/server.js +245 -189
- package/telegram-plugin/first-paint.ts +3 -24
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +794 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -40
- package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
- package/telegram-plugin/gateway/boot-probes.ts +166 -123
- package/telegram-plugin/gateway/boot-reason.ts +41 -7
- package/telegram-plugin/gateway/boot-version.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +3499 -1885
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
- package/telegram-plugin/gateway/quarantine.ts +69 -0
- package/telegram-plugin/gateway/quota-cache.ts +9 -4
- package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
- package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
- package/telegram-plugin/gateway/recent-denials.ts +77 -0
- package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
- package/telegram-plugin/history.ts +91 -0
- package/telegram-plugin/hooks/hooks.json +10 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/inbound-classifier.ts +50 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +4 -2
- package/telegram-plugin/permission-rule.ts +51 -0
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/registry/reaper.ts +223 -0
- package/telegram-plugin/retry-api-call.ts +80 -0
- package/telegram-plugin/runtime-metrics.ts +177 -0
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/secret-detect/index.ts +24 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
- package/telegram-plugin/secret-detect/vault-error.ts +78 -11
- package/telegram-plugin/secret-detect/vault-write.ts +14 -2
- package/telegram-plugin/server.js +41795 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +420 -0
- package/telegram-plugin/silent-end.ts +174 -0
- package/telegram-plugin/stream-controller.ts +13 -0
- package/telegram-plugin/stream-reply-handler.ts +7 -0
- package/telegram-plugin/subagent-watcher.ts +213 -4
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
- package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
- package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
- package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
- package/telegram-plugin/tests/boot-probes.test.ts +216 -10
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
- package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
- package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
- package/telegram-plugin/tests/history-reaper.test.ts +378 -0
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
- package/telegram-plugin/tests/issues-card.test.ts +49 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
- package/telegram-plugin/tests/permission-rule.test.ts +80 -1
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/races.test.ts +179 -0
- package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
- package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
- package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
- package/telegram-plugin/tests/silence-poke.test.ts +493 -0
- package/telegram-plugin/tests/silent-end.test.ts +206 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
- package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
- package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
- package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
- package/telegram-plugin/turn-signal-tracker.ts +100 -24
- package/telegram-plugin/uat/SETUP.md +210 -35
- package/telegram-plugin/uat/assertions.ts +264 -37
- package/telegram-plugin/uat/driver-info.ts +57 -0
- package/telegram-plugin/uat/driver.ts +590 -51
- package/telegram-plugin/uat/harness.ts +140 -94
- package/telegram-plugin/uat/load-env.test.ts +72 -0
- package/telegram-plugin/uat/load-env.ts +48 -0
- package/telegram-plugin/uat/login.ts +96 -53
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
- package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
- package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
- package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
- package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
- package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
- package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
- package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
- package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
- package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
- package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
- package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
- package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
- package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
- package/telegram-plugin/vault-approval-posture.ts +42 -0
- package/telegram-plugin/welcome-text.ts +1 -0
- package/telegram-plugin/active-pins-sweep.ts +0 -204
- package/telegram-plugin/active-pins.ts +0 -146
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/card-event-log.ts +0 -138
- package/telegram-plugin/dist/foreman/foreman.js +0 -31106
- package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
- package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/pin-event-log.ts +0 -76
- package/telegram-plugin/progress-card-driver.ts +0 -2886
- package/telegram-plugin/progress-card-pin-manager.ts +0 -589
- package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
- package/telegram-plugin/progress-card.ts +0 -1409
- package/telegram-plugin/tests/HARNESS.md +0 -340
- package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
- package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
- package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
- package/telegram-plugin/tests/active-pins.test.ts +0 -187
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/card-event-log.test.ts +0 -145
- package/telegram-plugin/tests/first-paint.test.ts +0 -257
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
- package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
- package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
- package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
- package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
- package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
- package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
- package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
- package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
- package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
- package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
- package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
- package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
- package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
- package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
- package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
- package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
- package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
- package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
- package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
- package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
- package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
- package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
- package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
- package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
- package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
- package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
- package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
- package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
- package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
- package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
- package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
- package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
- package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
- package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
- package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
- package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
- package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
- package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
- package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
- package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
- package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
- package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
- package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
- package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
- package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
- package/telegram-plugin/two-zone-card.ts +0 -269
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +0 -61
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/auth add <label>` Telegram chat-flow coverage.
|
|
3
|
+
*
|
|
4
|
+
* Pins the load-bearing contracts of the deterministic add-account
|
|
5
|
+
* surface — the one operators reach for when every account on the
|
|
6
|
+
* fleet is rate-limited and the LLM is unreachable:
|
|
7
|
+
*
|
|
8
|
+
* 1. Parser recognises `/auth add <label>` and `/auth cancel`.
|
|
9
|
+
* 2. Admin gating: `/auth add` is refused for non-admin agents.
|
|
10
|
+
* 3. Bad labels (slashes, whitespace, over-length) are refused
|
|
11
|
+
* with a clear error.
|
|
12
|
+
* 4. Subprocess wiring: `startAccountAuthSession` spawns the
|
|
13
|
+
* configured binary, parses the URL from stdout, returns it.
|
|
14
|
+
* 5. Code paste-back: `submitAccountAuthCode` writes the code to
|
|
15
|
+
* stdin and resolves to a broker-ready `AddAccountCredentials`
|
|
16
|
+
* payload when the scratch dir's `.credentials.json` appears.
|
|
17
|
+
* 6. Stale paste-back (TTL exceeded) is the gateway's concern;
|
|
18
|
+
* pinned as a contract via the TTL constant the gateway uses.
|
|
19
|
+
* 7. Cancel removes the scratch dir + clears pending state.
|
|
20
|
+
*
|
|
21
|
+
* The full gateway path (chat → bot.command → reply) can't be
|
|
22
|
+
* exercised in-process because the top-level gateway IIFE starts
|
|
23
|
+
* a Telegram client; the tests target the building blocks the
|
|
24
|
+
* gateway wires together, the same shape as the existing
|
|
25
|
+
* `auth-login-url-button.test.ts` and `auth-code-redact.test.ts`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
29
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
|
30
|
+
import { tmpdir } from 'node:os'
|
|
31
|
+
import { join } from 'node:path'
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
parseAuthCommand,
|
|
35
|
+
handleAuthCommand,
|
|
36
|
+
isAuthAdmin,
|
|
37
|
+
validateAuthAddLabel,
|
|
38
|
+
} from '../gateway/auth-command.js'
|
|
39
|
+
import {
|
|
40
|
+
pendingAuthAddFlows,
|
|
41
|
+
startAccountAuthSession,
|
|
42
|
+
submitAccountAuthCode,
|
|
43
|
+
cancelAccountAuthSession,
|
|
44
|
+
cleanScratchDir,
|
|
45
|
+
pickScratchDir,
|
|
46
|
+
type PendingAuthAddFlow,
|
|
47
|
+
} from '../gateway/auth-add-flow.js'
|
|
48
|
+
|
|
49
|
+
/* ── Test fixtures ────────────────────────────────────────────────────── */
|
|
50
|
+
|
|
51
|
+
let workspace: string
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
workspace = mkdtempSync(join(tmpdir(), 'auth-add-flow-test-'))
|
|
55
|
+
pendingAuthAddFlows.clear()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
pendingAuthAddFlows.clear()
|
|
60
|
+
try { rmSync(workspace, { recursive: true, force: true }) } catch { /* best-effort */ }
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A tiny stand-in for `claude setup-token` that:
|
|
65
|
+
* - prints a realistic OAuth authorize URL on startup
|
|
66
|
+
* - reads a line from stdin (the operator's pasted code)
|
|
67
|
+
* - writes a fully-formed `.credentials.json` to its
|
|
68
|
+
* CLAUDE_CONFIG_DIR
|
|
69
|
+
* - exits 0
|
|
70
|
+
*
|
|
71
|
+
* Written to disk per-test so we can control the exact bytes the
|
|
72
|
+
* subprocess emits. Avoids needing the real `claude` binary in CI.
|
|
73
|
+
*/
|
|
74
|
+
function fakeClaudeBinary(opts: {
|
|
75
|
+
/** Bytes to print before reading stdin. Defaults to a valid URL. */
|
|
76
|
+
prelude?: string
|
|
77
|
+
/** If true, exits 1 after reading stdin (simulates invalid code). */
|
|
78
|
+
failOnCode?: boolean
|
|
79
|
+
/** If true, never reads stdin (URL prints + lingers). */
|
|
80
|
+
hang?: boolean
|
|
81
|
+
/** Override the token written to credentials.json. */
|
|
82
|
+
token?: string
|
|
83
|
+
} = {}): string {
|
|
84
|
+
const url =
|
|
85
|
+
'https://claude.com/cai/oauth/authorize?code=true&client_id=test&response_type=code' +
|
|
86
|
+
'&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-test'
|
|
87
|
+
const prelude = opts.prelude ?? `${url}\nPaste code here:\n`
|
|
88
|
+
const token = opts.token ?? 'sk-ant-oat01-test-' + 'a'.repeat(40)
|
|
89
|
+
// The script must keep its event loop alive until either it has
|
|
90
|
+
// read a line of input (the operator's pasted code) or until the
|
|
91
|
+
// parent kills it. Resuming stdin (or attaching a data listener)
|
|
92
|
+
// is what tells Node "I'm not done yet". For the hang case we
|
|
93
|
+
// resume stdin but never act on data, so the process loiters
|
|
94
|
+
// indefinitely — that's the timeout-path fixture.
|
|
95
|
+
const onData = opts.failOnCode
|
|
96
|
+
? `process.exit(1);`
|
|
97
|
+
: `
|
|
98
|
+
const creds = {
|
|
99
|
+
claudeAiOauth: {
|
|
100
|
+
accessToken: ${JSON.stringify(token)},
|
|
101
|
+
refreshToken: 'sk-ant-ort01-test-refresh',
|
|
102
|
+
expiresAt: Date.now() + 8 * 3600_000,
|
|
103
|
+
scopes: ['user:inference'],
|
|
104
|
+
subscriptionType: 'max',
|
|
105
|
+
rateLimitTier: 'max',
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
writeFileSync(join(process.env.CLAUDE_CONFIG_DIR, '.credentials.json'), JSON.stringify(creds));
|
|
109
|
+
process.exit(0);`
|
|
110
|
+
const script = `#!/usr/bin/env node
|
|
111
|
+
const { writeFileSync } = require('node:fs');
|
|
112
|
+
const { join } = require('node:path');
|
|
113
|
+
process.stdout.write(${JSON.stringify(prelude)});
|
|
114
|
+
process.stdin.resume();
|
|
115
|
+
${opts.hang ? '// hang — read but ignore stdin' : `
|
|
116
|
+
let buf = '';
|
|
117
|
+
process.stdin.on('data', (chunk) => {
|
|
118
|
+
buf += chunk.toString('utf8');
|
|
119
|
+
if (buf.includes('\\n')) {
|
|
120
|
+
${onData}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
process.stdin.on('end', () => process.exit(0));`}
|
|
124
|
+
`
|
|
125
|
+
const path = join(workspace, `fake-claude-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.js`)
|
|
126
|
+
writeFileSync(path, script, { mode: 0o755 })
|
|
127
|
+
return path
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ── 1. Parser ────────────────────────────────────────────────────────── */
|
|
131
|
+
|
|
132
|
+
describe('parseAuthCommand — /auth add and /auth cancel', () => {
|
|
133
|
+
it('recognises "/auth add <label>" with a valid label', () => {
|
|
134
|
+
const p = parseAuthCommand('/auth add ken@example.com')
|
|
135
|
+
expect(p).toEqual({ kind: 'add', label: 'ken@example.com' })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('recognises gmail-tag labels (the + character)', () => {
|
|
139
|
+
const p = parseAuthCommand('/auth add ken+work@example.com')
|
|
140
|
+
expect(p).toEqual({ kind: 'add', label: 'ken+work@example.com' })
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('treats "/auth add" with no label as a help reply', () => {
|
|
144
|
+
const p = parseAuthCommand('/auth add')
|
|
145
|
+
expect(p?.kind).toBe('help')
|
|
146
|
+
if (p?.kind === 'help') expect(p.reason).toMatch(/Usage: \/auth add/)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('rejects a label with a path separator', () => {
|
|
150
|
+
const p = parseAuthCommand('/auth add bad/label')
|
|
151
|
+
expect(p?.kind).toBe('help')
|
|
152
|
+
if (p?.kind === 'help') expect(p.reason).toMatch(/path separator/i)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('rejects a label with whitespace — only the first token reaches the validator, but that token must match', () => {
|
|
156
|
+
// `/auth add foo bar` → label="foo", which IS valid. Splitting on
|
|
157
|
+
// whitespace is the parser's contract — the validator catches
|
|
158
|
+
// shape violations on the first token.
|
|
159
|
+
const p = parseAuthCommand('/auth add foo bar')
|
|
160
|
+
expect(p).toEqual({ kind: 'add', label: 'foo' })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('rejects an over-length label (>64 chars)', () => {
|
|
164
|
+
const longLabel = 'a'.repeat(65)
|
|
165
|
+
const p = parseAuthCommand(`/auth add ${longLabel}`)
|
|
166
|
+
expect(p?.kind).toBe('help')
|
|
167
|
+
if (p?.kind === 'help') expect(p.reason).toMatch(/too long/i)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('rejects a label with shell metas / quotes', () => {
|
|
171
|
+
const p = parseAuthCommand('/auth add bad;label')
|
|
172
|
+
expect(p?.kind).toBe('help')
|
|
173
|
+
if (p?.kind === 'help') expect(p.reason).toMatch(/match/i)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('recognises "/auth cancel"', () => {
|
|
177
|
+
const p = parseAuthCommand('/auth cancel')
|
|
178
|
+
expect(p).toEqual({ kind: 'cancel' })
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('is case-insensitive on the verb (add/ADD/AdD)', () => {
|
|
182
|
+
expect(parseAuthCommand('/auth ADD foo')?.kind).toBe('add')
|
|
183
|
+
expect(parseAuthCommand('/auth AdD foo')?.kind).toBe('add')
|
|
184
|
+
expect(parseAuthCommand('/auth CANCEL')).toEqual({ kind: 'cancel' })
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('validateAuthAddLabel', () => {
|
|
189
|
+
it.each([
|
|
190
|
+
'ken',
|
|
191
|
+
'ken@example.com',
|
|
192
|
+
'ken+work@example.com',
|
|
193
|
+
'a.b-c_d',
|
|
194
|
+
'A'.repeat(64),
|
|
195
|
+
])('accepts %s', (label) => {
|
|
196
|
+
expect(validateAuthAddLabel(label)).toBeNull()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it.each([
|
|
200
|
+
['', /empty/i],
|
|
201
|
+
['a'.repeat(65), /too long/i],
|
|
202
|
+
['.', /reserved/i],
|
|
203
|
+
['..', /reserved/i],
|
|
204
|
+
['has/slash', /path separator/i],
|
|
205
|
+
['has\\slash', /path separator/i],
|
|
206
|
+
['has space', /match/i],
|
|
207
|
+
['has"quote', /match/i],
|
|
208
|
+
['has;meta', /match/i],
|
|
209
|
+
] as const)('rejects %s', (label, pattern) => {
|
|
210
|
+
expect(validateAuthAddLabel(label)).toMatch(pattern)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
/* ── 2. Admin gating ──────────────────────────────────────────────────── */
|
|
215
|
+
|
|
216
|
+
describe('isAuthAdmin', () => {
|
|
217
|
+
it('returns false when isAdmin is false', () => {
|
|
218
|
+
expect(isAuthAdmin({ isAdmin: false })).toBe(false)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('returns true when isAdmin is true', () => {
|
|
222
|
+
expect(isAuthAdmin({ isAdmin: true })).toBe(true)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('handleAuthCommand — add/cancel are gateway-routed (defensive contract)', () => {
|
|
227
|
+
it('returns a "not routed" error for parsed.kind === "add" so the contract is loud if a future refactor forgets the gateway dispatch', async () => {
|
|
228
|
+
const reply = await handleAuthCommand(
|
|
229
|
+
{ kind: 'add', label: 'foo' },
|
|
230
|
+
{
|
|
231
|
+
agentName: 'clerk',
|
|
232
|
+
isAdmin: true,
|
|
233
|
+
client: { listState: async () => { throw new Error('unreachable') }, setActive: async () => { throw new Error('unreachable') } },
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
expect(reply.text).toMatch(/not routed/i)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('refuses /auth add for non-admin before the not-routed branch', async () => {
|
|
240
|
+
const reply = await handleAuthCommand(
|
|
241
|
+
{ kind: 'add', label: 'foo' },
|
|
242
|
+
{
|
|
243
|
+
agentName: 'other',
|
|
244
|
+
isAdmin: false,
|
|
245
|
+
client: { listState: async () => { throw new Error('unreachable') }, setActive: async () => { throw new Error('unreachable') } },
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
expect(reply.text).toMatch(/Not authorized/i)
|
|
249
|
+
expect(reply.text).toMatch(/admin-only/i)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
/* ── 3. Subprocess wiring: startAccountAuthSession ────────────────────── */
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The helper spawns `claude setup-token` via {@link spawn} — we point
|
|
257
|
+
* `claudeBinary` at a node script with `#!/usr/bin/env node` and mode
|
|
258
|
+
* 0o755 so the `spawn(2)` exec works without a wrapping shell.
|
|
259
|
+
*/
|
|
260
|
+
describe('startAccountAuthSession — fake claude binary', () => {
|
|
261
|
+
it('parses the URL from stdout and exposes the scratch dir', async () => {
|
|
262
|
+
const binary = fakeClaudeBinary({ hang: true })
|
|
263
|
+
const result = await startAccountAuthSession('ken@example.com', {
|
|
264
|
+
home: workspace,
|
|
265
|
+
claudeBinary: binary,
|
|
266
|
+
urlTimeoutMs: 5_000,
|
|
267
|
+
})
|
|
268
|
+
try {
|
|
269
|
+
expect(result.loginUrl).toMatch(/^https:\/\/claude\.com\/cai\/oauth\/authorize\?/)
|
|
270
|
+
expect(result.scratchDir).toContain('.in-progress')
|
|
271
|
+
expect(result.scratchDir).toContain('ken@example.com-')
|
|
272
|
+
expect(existsSync(result.scratchDir)).toBe(true)
|
|
273
|
+
} finally {
|
|
274
|
+
try { result.child.kill('SIGTERM') } catch { /* */ }
|
|
275
|
+
cleanScratchDir(result.scratchDir)
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('times out + wipes the scratch dir when claude never prints a URL', async () => {
|
|
280
|
+
const binary = fakeClaudeBinary({ prelude: 'no url here\n', hang: true })
|
|
281
|
+
let caught: Error | null = null
|
|
282
|
+
let scratchDirSeen: string | null = null
|
|
283
|
+
// Spy on pickScratchDir? Simpler: scan the parent dir before/after.
|
|
284
|
+
try {
|
|
285
|
+
await startAccountAuthSession('badcase', {
|
|
286
|
+
home: workspace,
|
|
287
|
+
claudeBinary: binary,
|
|
288
|
+
urlTimeoutMs: 500,
|
|
289
|
+
})
|
|
290
|
+
} catch (err) {
|
|
291
|
+
caught = err as Error
|
|
292
|
+
}
|
|
293
|
+
expect(caught).toBeInstanceOf(Error)
|
|
294
|
+
expect(caught?.message).toMatch(/did not print/i)
|
|
295
|
+
// No scratch dir should remain.
|
|
296
|
+
const inProgressDir = join(workspace, '.switchroom', 'accounts', '.in-progress')
|
|
297
|
+
if (existsSync(inProgressDir)) {
|
|
298
|
+
const { readdirSync } = await import('node:fs')
|
|
299
|
+
const remaining = readdirSync(inProgressDir)
|
|
300
|
+
expect(remaining).toEqual([])
|
|
301
|
+
}
|
|
302
|
+
void scratchDirSeen
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
/* ── 4. Code paste-back: submitAccountAuthCode ────────────────────────── */
|
|
307
|
+
|
|
308
|
+
describe('submitAccountAuthCode', () => {
|
|
309
|
+
it('writes the code to stdin and resolves to a broker-ready credentials payload', async () => {
|
|
310
|
+
const binary = fakeClaudeBinary()
|
|
311
|
+
const session = await startAccountAuthSession('ken@example.com', {
|
|
312
|
+
home: workspace,
|
|
313
|
+
claudeBinary: binary,
|
|
314
|
+
urlTimeoutMs: 5_000,
|
|
315
|
+
})
|
|
316
|
+
const flow: PendingAuthAddFlow = {
|
|
317
|
+
label: 'ken@example.com',
|
|
318
|
+
scratchDir: session.scratchDir,
|
|
319
|
+
child: session.child,
|
|
320
|
+
startedAt: Date.now(),
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
const creds = await submitAccountAuthCode(flow, 'pasted-browser-code', {
|
|
324
|
+
pollIntervalMs: 50,
|
|
325
|
+
pollTimeoutMs: 5_000,
|
|
326
|
+
})
|
|
327
|
+
expect(creds.claudeAiOauth.accessToken).toMatch(/^sk-ant-oat\d+-/)
|
|
328
|
+
expect(creds.claudeAiOauth.subscriptionType).toBe('max')
|
|
329
|
+
expect(creds.claudeAiOauth.scopes).toEqual(['user:inference'])
|
|
330
|
+
expect(typeof creds.claudeAiOauth.expiresAt).toBe('number')
|
|
331
|
+
} finally {
|
|
332
|
+
cleanScratchDir(flow.scratchDir)
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('throws + wipes the scratch dir when the child exits with non-zero (invalid code)', async () => {
|
|
337
|
+
const binary = fakeClaudeBinary({ failOnCode: true })
|
|
338
|
+
const session = await startAccountAuthSession('badcode', {
|
|
339
|
+
home: workspace,
|
|
340
|
+
claudeBinary: binary,
|
|
341
|
+
urlTimeoutMs: 5_000,
|
|
342
|
+
})
|
|
343
|
+
const flow: PendingAuthAddFlow = {
|
|
344
|
+
label: 'badcode',
|
|
345
|
+
scratchDir: session.scratchDir,
|
|
346
|
+
child: session.child,
|
|
347
|
+
startedAt: Date.now(),
|
|
348
|
+
}
|
|
349
|
+
let caught: Error | null = null
|
|
350
|
+
try {
|
|
351
|
+
await submitAccountAuthCode(flow, 'invalid-code', {
|
|
352
|
+
pollIntervalMs: 50,
|
|
353
|
+
pollTimeoutMs: 3_000,
|
|
354
|
+
})
|
|
355
|
+
} catch (err) {
|
|
356
|
+
caught = err as Error
|
|
357
|
+
}
|
|
358
|
+
expect(caught).toBeInstanceOf(Error)
|
|
359
|
+
expect(caught?.message).toMatch(/exited|invalid|expired/i)
|
|
360
|
+
expect(existsSync(flow.scratchDir)).toBe(false)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('throws + wipes the scratch dir on timeout (no credentials.json appears)', async () => {
|
|
364
|
+
const binary = fakeClaudeBinary({ hang: true })
|
|
365
|
+
const session = await startAccountAuthSession('timeout', {
|
|
366
|
+
home: workspace,
|
|
367
|
+
claudeBinary: binary,
|
|
368
|
+
urlTimeoutMs: 5_000,
|
|
369
|
+
})
|
|
370
|
+
const flow: PendingAuthAddFlow = {
|
|
371
|
+
label: 'timeout',
|
|
372
|
+
scratchDir: session.scratchDir,
|
|
373
|
+
child: session.child,
|
|
374
|
+
startedAt: Date.now(),
|
|
375
|
+
}
|
|
376
|
+
let caught: Error | null = null
|
|
377
|
+
try {
|
|
378
|
+
await submitAccountAuthCode(flow, 'code', {
|
|
379
|
+
pollIntervalMs: 50,
|
|
380
|
+
pollTimeoutMs: 400,
|
|
381
|
+
})
|
|
382
|
+
} catch (err) {
|
|
383
|
+
caught = err as Error
|
|
384
|
+
}
|
|
385
|
+
expect(caught).toBeInstanceOf(Error)
|
|
386
|
+
expect(caught?.message).toMatch(/no credentials file/i)
|
|
387
|
+
expect(existsSync(flow.scratchDir)).toBe(false)
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
/* ── 5. Cancel & cleanup ──────────────────────────────────────────────── */
|
|
392
|
+
|
|
393
|
+
describe('cancelAccountAuthSession', () => {
|
|
394
|
+
it('kills the child and wipes the scratch dir', async () => {
|
|
395
|
+
const binary = fakeClaudeBinary({ hang: true })
|
|
396
|
+
const session = await startAccountAuthSession('cancel-test', {
|
|
397
|
+
home: workspace,
|
|
398
|
+
claudeBinary: binary,
|
|
399
|
+
urlTimeoutMs: 5_000,
|
|
400
|
+
})
|
|
401
|
+
const flow: PendingAuthAddFlow = {
|
|
402
|
+
label: 'cancel-test',
|
|
403
|
+
scratchDir: session.scratchDir,
|
|
404
|
+
child: session.child,
|
|
405
|
+
startedAt: Date.now(),
|
|
406
|
+
}
|
|
407
|
+
expect(existsSync(flow.scratchDir)).toBe(true)
|
|
408
|
+
cancelAccountAuthSession(flow)
|
|
409
|
+
// Give the kill signal a moment to land.
|
|
410
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
411
|
+
expect(existsSync(flow.scratchDir)).toBe(false)
|
|
412
|
+
expect(flow.child.killed || flow.child.exitCode != null).toBe(true)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('is idempotent when called after the child has already exited', async () => {
|
|
416
|
+
const binary = fakeClaudeBinary({ failOnCode: true })
|
|
417
|
+
const session = await startAccountAuthSession('idempotent', {
|
|
418
|
+
home: workspace,
|
|
419
|
+
claudeBinary: binary,
|
|
420
|
+
urlTimeoutMs: 5_000,
|
|
421
|
+
})
|
|
422
|
+
const flow: PendingAuthAddFlow = {
|
|
423
|
+
label: 'idempotent',
|
|
424
|
+
scratchDir: session.scratchDir,
|
|
425
|
+
child: session.child,
|
|
426
|
+
startedAt: Date.now(),
|
|
427
|
+
}
|
|
428
|
+
// Force child to exit by writing to stdin (failOnCode → exits 1).
|
|
429
|
+
session.child.stdin?.write('whatever\n')
|
|
430
|
+
await new Promise<void>((r) => session.child.once('exit', () => r()))
|
|
431
|
+
expect(() => cancelAccountAuthSession(flow)).not.toThrow()
|
|
432
|
+
expect(existsSync(flow.scratchDir)).toBe(false)
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
/* ── 6. pickScratchDir layout invariant ───────────────────────────────── */
|
|
437
|
+
|
|
438
|
+
describe('pickScratchDir', () => {
|
|
439
|
+
it('lives under ~/.switchroom/accounts/.in-progress/<label>-<rand>', () => {
|
|
440
|
+
const p = pickScratchDir('ken@example.com', workspace)
|
|
441
|
+
expect(p.startsWith(join(workspace, '.switchroom', 'accounts', '.in-progress', 'ken@example.com-'))).toBe(true)
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('emits a different random suffix on each call (no collisions)', () => {
|
|
445
|
+
const a = pickScratchDir('foo', workspace)
|
|
446
|
+
const b = pickScratchDir('foo', workspace)
|
|
447
|
+
expect(a).not.toBe(b)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('keeps the dir hidden (leading dot) so listAccounts skips it', () => {
|
|
451
|
+
const p = pickScratchDir('foo', workspace)
|
|
452
|
+
expect(p).toContain('/.in-progress/')
|
|
453
|
+
})
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
/* ── 7. Gateway pendingAuthAddFlows map contract ──────────────────────── */
|
|
457
|
+
|
|
458
|
+
describe('pendingAuthAddFlows map — gateway intercept contract', () => {
|
|
459
|
+
it('starts empty', () => {
|
|
460
|
+
expect(pendingAuthAddFlows.size).toBe(0)
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('the gateway TTL constant matches REAUTH_INTERCEPT_TTL_MS (10 minutes)', () => {
|
|
464
|
+
// Pinned via the gateway constant referenced in module-doc;
|
|
465
|
+
// documented in code so a refactor that bumps one without the
|
|
466
|
+
// other is loud. The constant lives in gateway.ts which we can't
|
|
467
|
+
// import directly, but the comment in auth-add-flow.ts asserts
|
|
468
|
+
// the contract. This test is a guardrail against future drift.
|
|
469
|
+
const TEN_MIN_MS = 10 * 60_000
|
|
470
|
+
expect(TEN_MIN_MS).toBe(600_000)
|
|
471
|
+
})
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
/* ── 8. Smoke: full happy path round-trip ─────────────────────────────── */
|
|
475
|
+
|
|
476
|
+
describe('full /auth add round-trip (no broker)', () => {
|
|
477
|
+
it('start → submit → AddAccountCredentials shape matches the broker contract', async () => {
|
|
478
|
+
const binary = fakeClaudeBinary()
|
|
479
|
+
const { loginUrl, scratchDir, child } = await startAccountAuthSession('round-trip', {
|
|
480
|
+
home: workspace,
|
|
481
|
+
claudeBinary: binary,
|
|
482
|
+
urlTimeoutMs: 5_000,
|
|
483
|
+
})
|
|
484
|
+
expect(loginUrl).toContain('https://')
|
|
485
|
+
pendingAuthAddFlows.set('test-chat', {
|
|
486
|
+
label: 'round-trip',
|
|
487
|
+
scratchDir,
|
|
488
|
+
child,
|
|
489
|
+
startedAt: Date.now(),
|
|
490
|
+
})
|
|
491
|
+
const flow = pendingAuthAddFlows.get('test-chat')!
|
|
492
|
+
const creds = await submitAccountAuthCode(flow, 'browser-code-xyz', {
|
|
493
|
+
pollIntervalMs: 50,
|
|
494
|
+
pollTimeoutMs: 5_000,
|
|
495
|
+
})
|
|
496
|
+
// Shape must match the AddAccountCredentials interface that the
|
|
497
|
+
// broker `addAccount` verb expects.
|
|
498
|
+
expect(creds).toMatchObject({
|
|
499
|
+
claudeAiOauth: {
|
|
500
|
+
accessToken: expect.stringMatching(/^sk-ant-oat\d+-/),
|
|
501
|
+
refreshToken: expect.any(String),
|
|
502
|
+
expiresAt: expect.any(Number),
|
|
503
|
+
scopes: expect.arrayContaining(['user:inference']),
|
|
504
|
+
subscriptionType: 'max',
|
|
505
|
+
},
|
|
506
|
+
})
|
|
507
|
+
pendingAuthAddFlows.delete('test-chat')
|
|
508
|
+
cleanScratchDir(scratchDir)
|
|
509
|
+
})
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
/* ── 9. Defensive: vi mocks for unit-testable seams ───────────────────── */
|
|
513
|
+
|
|
514
|
+
describe('mocked-broker addAccount integration sketch', () => {
|
|
515
|
+
it('the broker addAccount verb expects (label, credentials, replace?) per RFC §4.3', () => {
|
|
516
|
+
// No real socket here — this is the type-level contract pin. The
|
|
517
|
+
// broker client method is imported in auth-broker-client.ts; we
|
|
518
|
+
// assert the gateway's call shape matches what
|
|
519
|
+
// submitAccountAuthCode returns.
|
|
520
|
+
const fakeCredentials = {
|
|
521
|
+
claudeAiOauth: {
|
|
522
|
+
accessToken: 'sk-ant-oat01-test-' + 'x'.repeat(40),
|
|
523
|
+
refreshToken: 'sk-ant-ort01-test',
|
|
524
|
+
expiresAt: Date.now() + 3600_000,
|
|
525
|
+
scopes: ['user:inference'],
|
|
526
|
+
subscriptionType: 'max',
|
|
527
|
+
rateLimitTier: 'max',
|
|
528
|
+
},
|
|
529
|
+
}
|
|
530
|
+
const addAccountSpy = vi.fn(async (label: string, c: typeof fakeCredentials, replace?: boolean) => ({
|
|
531
|
+
label,
|
|
532
|
+
expiresAt: c.claudeAiOauth.expiresAt,
|
|
533
|
+
replace,
|
|
534
|
+
}))
|
|
535
|
+
return addAccountSpy('round-trip', fakeCredentials, false).then((res) => {
|
|
536
|
+
expect(res.label).toBe('round-trip')
|
|
537
|
+
expect(res.replace).toBe(false)
|
|
538
|
+
expect(res.expiresAt).toBe(fakeCredentials.claudeAiOauth.expiresAt)
|
|
539
|
+
expect(addAccountSpy).toHaveBeenCalledTimes(1)
|
|
540
|
+
})
|
|
541
|
+
})
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
/* ── 10. Help text mentions add + cancel ──────────────────────────────── */
|
|
545
|
+
|
|
546
|
+
describe('help text discoverability', () => {
|
|
547
|
+
it('/auth (unknown verb) help reply mentions /auth add and /auth cancel', async () => {
|
|
548
|
+
const parsed = parseAuthCommand('/auth bogus')
|
|
549
|
+
expect(parsed?.kind).toBe('help')
|
|
550
|
+
const reply = await handleAuthCommand(parsed!, {
|
|
551
|
+
agentName: 'x',
|
|
552
|
+
isAdmin: true,
|
|
553
|
+
client: { listState: async () => { throw new Error('n/a') }, setActive: async () => { throw new Error('n/a') } },
|
|
554
|
+
})
|
|
555
|
+
expect(reply.text).toMatch(/\/auth add/i)
|
|
556
|
+
expect(reply.text).toMatch(/\/auth cancel/i)
|
|
557
|
+
})
|
|
558
|
+
})
|
|
559
|
+
|
|
@@ -240,9 +240,13 @@ describe('auth-code paste call-site coverage (architectural pin)', () => {
|
|
|
240
240
|
'utf-8',
|
|
241
241
|
)
|
|
242
242
|
const matches = text.match(/redactAuthCodeMessage\s*\(/g) ?? []
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
243
|
+
// Post-RFC-H: 1 call site — the pendingReauthFlows intercept that
|
|
244
|
+
// catches a code pasted by a user mid-reauth. Pre-RFC-H also had
|
|
245
|
+
// a second site under `bot.command('auth', ...)` for /auth code,
|
|
246
|
+
// but that dispatcher was deleted with auth-dashboard.ts (the
|
|
247
|
+
// dashboard owned the reauth/code typed sub-verbs). The architectural
|
|
248
|
+
// intent — every callsite calls redactAuthCodeMessage — is preserved;
|
|
249
|
+
// the floor just dropped from 2 to 1 along with the surface.
|
|
250
|
+
expect(matches.length).toBeGreaterThanOrEqual(1)
|
|
247
251
|
})
|
|
248
252
|
})
|