switchroom 0.19.1 → 0.19.3
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/dist/agent-scheduler/index.js +31 -1
- package/dist/auth-broker/index.js +565 -48
- package/dist/cli/autoaccept-poll.js +31 -1
- package/dist/cli/drive-write-pretool.mjs +32 -2
- package/dist/cli/ms-365-write-pretool.mjs +32 -2
- package/dist/cli/switchroom.js +1148 -274
- package/dist/host-control/main.js +3 -3
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +1 -0
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/skills/switchroom-cli/SKILL.md +25 -0
- package/telegram-plugin/auth-snapshot-format.ts +143 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1427 -689
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/external-spend.ts +135 -0
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +141 -158
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +309 -1
- package/telegram-plugin/gateway/narrative-lane.ts +23 -9
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/status-pin-store.ts +64 -4
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/gateway/usage-mask.ts +29 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
- package/telegram-plugin/quota-bar-format.ts +78 -12
- package/telegram-plugin/quota-check.ts +17 -2
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/external-spend.test.ts +168 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
- package/telegram-plugin/tests/quota-check.test.ts +57 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
- package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
- package/telegram-plugin/tests/usage-mask.test.ts +35 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -60,6 +60,10 @@ import {
|
|
|
60
60
|
handleAuthCommand,
|
|
61
61
|
isAuthAdmin,
|
|
62
62
|
validateAuthAddLabel,
|
|
63
|
+
readdPrecheckError,
|
|
64
|
+
runReaddPrecheck,
|
|
65
|
+
formatGrantedScopesReply,
|
|
66
|
+
REQUIRED_USAGE_SCOPE,
|
|
63
67
|
} from '../gateway/auth-command.js'
|
|
64
68
|
import {
|
|
65
69
|
pendingAuthAddFlows,
|
|
@@ -111,6 +115,7 @@ function makeMockTmuxOps(opts: {
|
|
|
111
115
|
} = {}): AuthAddTmuxOps & {
|
|
112
116
|
newSessionCalls: Array<{ socket: string; session: string; env: Record<string, string>; cmd: string }>
|
|
113
117
|
sendCalls: Array<{ socket: string; session: string; text: string }>
|
|
118
|
+
sendKeyCalls: Array<{ socket: string; session: string; key: string }>
|
|
114
119
|
killCalls: Array<{ socket: string; session: string }>
|
|
115
120
|
captureCallCount: number
|
|
116
121
|
sessionAlive: boolean
|
|
@@ -123,12 +128,14 @@ function makeMockTmuxOps(opts: {
|
|
|
123
128
|
let sessionAlive = opts.initialSessionAlive ?? true
|
|
124
129
|
const newSessionCalls: Array<{ socket: string; session: string; env: Record<string, string>; cmd: string }> = []
|
|
125
130
|
const sendCalls: Array<{ socket: string; session: string; text: string }> = []
|
|
131
|
+
const sendKeyCalls: Array<{ socket: string; session: string; key: string }> = []
|
|
126
132
|
const killCalls: Array<{ socket: string; session: string }> = []
|
|
127
133
|
let captureCallCount = 0
|
|
128
134
|
|
|
129
135
|
const mock = {
|
|
130
136
|
get newSessionCalls() { return newSessionCalls },
|
|
131
137
|
get sendCalls() { return sendCalls },
|
|
138
|
+
get sendKeyCalls() { return sendKeyCalls },
|
|
132
139
|
get killCalls() { return killCalls },
|
|
133
140
|
get captureCallCount() { return captureCallCount },
|
|
134
141
|
get sessionAlive() { return sessionAlive },
|
|
@@ -149,6 +156,9 @@ function makeMockTmuxOps(opts: {
|
|
|
149
156
|
sendCalls.push({ socket, session, text })
|
|
150
157
|
mock.onSend?.(socket, session, text)
|
|
151
158
|
},
|
|
159
|
+
sendKey(socket: string, session: string, key: string) {
|
|
160
|
+
sendKeyCalls.push({ socket, session, key })
|
|
161
|
+
},
|
|
152
162
|
hasSession(socket: string, session: string): boolean {
|
|
153
163
|
void socket; void session
|
|
154
164
|
return sessionAlive
|
|
@@ -166,12 +176,12 @@ function makeMockTmuxOps(opts: {
|
|
|
166
176
|
describe('parseAuthCommand — /auth add and /auth cancel', () => {
|
|
167
177
|
it('recognises "/auth add <label>" with a valid label', () => {
|
|
168
178
|
const p = parseAuthCommand('/auth add alice@example.com')
|
|
169
|
-
expect(p).toEqual({ kind: 'add', label: 'alice@example.com' })
|
|
179
|
+
expect(p).toEqual({ kind: 'add', label: 'alice@example.com', replace: false })
|
|
170
180
|
})
|
|
171
181
|
|
|
172
182
|
it('recognises gmail-tag labels (the + character)', () => {
|
|
173
183
|
const p = parseAuthCommand('/auth add alice+work@example.com')
|
|
174
|
-
expect(p).toEqual({ kind: 'add', label: 'alice+work@example.com' })
|
|
184
|
+
expect(p).toEqual({ kind: 'add', label: 'alice+work@example.com', replace: false })
|
|
175
185
|
})
|
|
176
186
|
|
|
177
187
|
it('treats "/auth add" with no label as a help reply', () => {
|
|
@@ -191,7 +201,7 @@ describe('parseAuthCommand — /auth add and /auth cancel', () => {
|
|
|
191
201
|
// whitespace is the parser's contract — the validator catches
|
|
192
202
|
// shape violations on the first token.
|
|
193
203
|
const p = parseAuthCommand('/auth add foo bar')
|
|
194
|
-
expect(p).toEqual({ kind: 'add', label: 'foo' })
|
|
204
|
+
expect(p).toEqual({ kind: 'add', label: 'foo', replace: false })
|
|
195
205
|
})
|
|
196
206
|
|
|
197
207
|
it('rejects an over-length label (>64 chars)', () => {
|
|
@@ -260,7 +270,7 @@ describe('isAuthAdmin', () => {
|
|
|
260
270
|
describe('handleAuthCommand — add/cancel are gateway-routed (defensive contract)', () => {
|
|
261
271
|
it('returns a "not routed" error for parsed.kind === "add" so the contract is loud if a future refactor forgets the gateway dispatch', async () => {
|
|
262
272
|
const reply = await handleAuthCommand(
|
|
263
|
-
{ kind: 'add', label: 'foo' },
|
|
273
|
+
{ kind: 'add', label: 'foo', replace: false },
|
|
264
274
|
{
|
|
265
275
|
agentName: 'clerk',
|
|
266
276
|
isAdmin: true,
|
|
@@ -272,7 +282,7 @@ describe('handleAuthCommand — add/cancel are gateway-routed (defensive contrac
|
|
|
272
282
|
|
|
273
283
|
it('refuses /auth add for non-admin before the not-routed branch', async () => {
|
|
274
284
|
const reply = await handleAuthCommand(
|
|
275
|
-
{ kind: 'add', label: 'foo' },
|
|
285
|
+
{ kind: 'add', label: 'foo', replace: false },
|
|
276
286
|
{
|
|
277
287
|
agentName: 'other',
|
|
278
288
|
isAdmin: false,
|
|
@@ -657,6 +667,161 @@ describe('mocked-broker addAccount integration sketch', () => {
|
|
|
657
667
|
expect(addAccountSpy).toHaveBeenCalledTimes(1)
|
|
658
668
|
})
|
|
659
669
|
})
|
|
670
|
+
|
|
671
|
+
it('readd threads replace=true through to the broker addAccount verb', async () => {
|
|
672
|
+
const fakeCredentials = {
|
|
673
|
+
claudeAiOauth: {
|
|
674
|
+
accessToken: 'sk-ant-oat01-test-' + 'x'.repeat(40),
|
|
675
|
+
expiresAt: Date.now() + 3600_000,
|
|
676
|
+
scopes: ['org:create_api_key', 'user:profile', 'user:inference'],
|
|
677
|
+
},
|
|
678
|
+
}
|
|
679
|
+
const addAccountSpy = vi.fn(async (label: string, _c: typeof fakeCredentials, replace?: boolean) => ({
|
|
680
|
+
label,
|
|
681
|
+
replace,
|
|
682
|
+
}))
|
|
683
|
+
// Plain add → replace false; readd → replace true. Both round-trip.
|
|
684
|
+
const added = await addAccountSpy('pooled@example.com', fakeCredentials, false)
|
|
685
|
+
const readded = await addAccountSpy('pooled@example.com', fakeCredentials, true)
|
|
686
|
+
expect(added.replace).toBe(false)
|
|
687
|
+
expect(readded.replace).toBe(true)
|
|
688
|
+
expect(addAccountSpy).toHaveBeenCalledTimes(2)
|
|
689
|
+
})
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
/* ── 9b. /auth readd parser + precheck + scope reply (PR C) ────────────── */
|
|
693
|
+
|
|
694
|
+
describe('parseAuthCommand — /auth readd and add --replace', () => {
|
|
695
|
+
it('parses "/auth readd <label>" as an add with replace=true', () => {
|
|
696
|
+
expect(parseAuthCommand('/auth readd pooled@example.com')).toEqual({
|
|
697
|
+
kind: 'add',
|
|
698
|
+
label: 'pooled@example.com',
|
|
699
|
+
replace: true,
|
|
700
|
+
})
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
it('parses "/auth add <label> --replace" as replace=true too', () => {
|
|
704
|
+
expect(parseAuthCommand('/auth add pooled@example.com --replace')).toEqual({
|
|
705
|
+
kind: 'add',
|
|
706
|
+
label: 'pooled@example.com',
|
|
707
|
+
replace: true,
|
|
708
|
+
})
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it('plain "/auth add <label>" stays replace=false', () => {
|
|
712
|
+
expect(parseAuthCommand('/auth add fresh@example.com')).toEqual({
|
|
713
|
+
kind: 'add',
|
|
714
|
+
label: 'fresh@example.com',
|
|
715
|
+
replace: false,
|
|
716
|
+
})
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
it('"/auth readd" with no label is a help reply', () => {
|
|
720
|
+
const p = parseAuthCommand('/auth readd')
|
|
721
|
+
expect(p?.kind).toBe('help')
|
|
722
|
+
if (p?.kind === 'help') expect(p.reason).toMatch(/Usage: \/auth readd/)
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
it('rejects an unknown flag on add/readd', () => {
|
|
726
|
+
const p = parseAuthCommand('/auth add foo --wipe')
|
|
727
|
+
expect(p?.kind).toBe('help')
|
|
728
|
+
if (p?.kind === 'help') expect(p.reason).toMatch(/Unknown flag/i)
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it('is case-insensitive on the readd verb', () => {
|
|
732
|
+
expect(parseAuthCommand('/auth READD foo')).toEqual({ kind: 'add', label: 'foo', replace: true })
|
|
733
|
+
})
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
describe('readdPrecheckError — exists gate', () => {
|
|
737
|
+
it('readd of a NONEXISTENT label errors clearly', () => {
|
|
738
|
+
const err = readdPrecheckError('ghost@example.com', true, false)
|
|
739
|
+
expect(err).toMatch(/no account named/i)
|
|
740
|
+
expect(err).toMatch(/ghost@example\.com/)
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
it('readd of an EXISTING label passes (null)', () => {
|
|
744
|
+
expect(readdPrecheckError('pooled@example.com', true, true)).toBeNull()
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
it('plain add of an EXISTING label errors and points at readd', () => {
|
|
748
|
+
const err = readdPrecheckError('pooled@example.com', false, true)
|
|
749
|
+
expect(err).toMatch(/already exists/i)
|
|
750
|
+
expect(err).toMatch(/\/auth readd/)
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
it('plain add of a NEW label passes (null)', () => {
|
|
754
|
+
expect(readdPrecheckError('fresh@example.com', false, false)).toBeNull()
|
|
755
|
+
})
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
describe('runReaddPrecheck — broker-backed wrapper (extracted from gateway.ts)', () => {
|
|
759
|
+
const clientWith = (labels: string[]) => ({
|
|
760
|
+
listState: async () => ({ accounts: labels.map((label) => ({ label })) }) as any,
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
it('readd of an EXISTING label queries the broker and passes (null)', async () => {
|
|
764
|
+
const err = await runReaddPrecheck(
|
|
765
|
+
async () => clientWith(['pooled@example.com']) as any,
|
|
766
|
+
'pooled@example.com',
|
|
767
|
+
true,
|
|
768
|
+
)
|
|
769
|
+
expect(err).toBeNull()
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
it('readd of a NONEXISTENT label surfaces the precheck error', async () => {
|
|
773
|
+
const err = await runReaddPrecheck(
|
|
774
|
+
async () => clientWith([]) as any,
|
|
775
|
+
'ghost@example.com',
|
|
776
|
+
true,
|
|
777
|
+
)
|
|
778
|
+
expect(err).toMatch(/no account named/i)
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
it('plain add of an EXISTING label surfaces the precheck error', async () => {
|
|
782
|
+
const err = await runReaddPrecheck(
|
|
783
|
+
async () => clientWith(['pooled@example.com']) as any,
|
|
784
|
+
'pooled@example.com',
|
|
785
|
+
false,
|
|
786
|
+
)
|
|
787
|
+
expect(err).toMatch(/already exists/i)
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
it('best-effort: null client (broker unreachable) skips the precheck (null)', async () => {
|
|
791
|
+
const err = await runReaddPrecheck(async () => null, 'x@example.com', true)
|
|
792
|
+
expect(err).toBeNull()
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
it('best-effort: a throwing broker is swallowed and skips the precheck (null)', async () => {
|
|
796
|
+
const err = await runReaddPrecheck(
|
|
797
|
+
async () => ({ listState: async () => { throw new Error('broker down') } }) as any,
|
|
798
|
+
'x@example.com',
|
|
799
|
+
false,
|
|
800
|
+
)
|
|
801
|
+
expect(err).toBeNull()
|
|
802
|
+
})
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
describe('formatGrantedScopesReply — scope-in-reply (structured, no scraping)', () => {
|
|
806
|
+
it('lists granted scopes and confirms when user:profile is present', () => {
|
|
807
|
+
const r = formatGrantedScopesReply(['org:create_api_key', 'user:profile', 'user:inference'])
|
|
808
|
+
expect(r.hasUsageScope).toBe(true)
|
|
809
|
+
expect(r.text).toContain('user:profile')
|
|
810
|
+
expect(r.text).toMatch(/unlocked/i)
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
it('warns LOUDLY when user:profile is absent (the setup-token footgun)', () => {
|
|
814
|
+
const r = formatGrantedScopesReply(['user:inference'])
|
|
815
|
+
expect(r.hasUsageScope).toBe(false)
|
|
816
|
+
expect(r.text).toMatch(/MISSING/)
|
|
817
|
+
expect(r.text).toContain(REQUIRED_USAGE_SCOPE)
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
it('warns when the token reports no scopes at all', () => {
|
|
821
|
+
const r = formatGrantedScopesReply(undefined)
|
|
822
|
+
expect(r.hasUsageScope).toBe(false)
|
|
823
|
+
expect(r.text).toMatch(/No scopes reported/i)
|
|
824
|
+
})
|
|
660
825
|
})
|
|
661
826
|
|
|
662
827
|
/* ── 10. Help text mentions add + cancel ─────────────────────────────── */
|
|
@@ -773,6 +938,9 @@ printf '{\\n "claudeAiOauth": {\\n "accessToken": "${fakeAccessToken}",\\n
|
|
|
773
938
|
send(_socket, session, text) {
|
|
774
939
|
return realOps.send(tmuxSocket, session, text)
|
|
775
940
|
},
|
|
941
|
+
sendKey(_socket, session, key) {
|
|
942
|
+
return realOps.sendKey(tmuxSocket, session, key)
|
|
943
|
+
},
|
|
776
944
|
hasSession(_socket, session) {
|
|
777
945
|
return realOps.hasSession(tmuxSocket, session)
|
|
778
946
|
},
|
|
@@ -816,6 +984,200 @@ printf '{\\n "claudeAiOauth": {\\n "accessToken": "${fakeAccessToken}",\\n
|
|
|
816
984
|
}, 30_000)
|
|
817
985
|
})
|
|
818
986
|
|
|
987
|
+
/* ── 12. via-claude broad-scope minter (PR B) ─────────────────────────── */
|
|
988
|
+
|
|
989
|
+
describe('startAccountAuthSession — via-claude mode (broad scope)', () => {
|
|
990
|
+
// The picker-rendered broad-scope authorize URL (org:create_api_key +
|
|
991
|
+
// user:profile + user:inference …). This is what `claude` (not setup-token)
|
|
992
|
+
// emits after "Claude account with subscription".
|
|
993
|
+
const BROAD_URL =
|
|
994
|
+
'https://claude.com/cai/oauth/authorize?code=true&client_id=x&response_type=code' +
|
|
995
|
+
'&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference+user%3Asessions%3Aclaude_code' +
|
|
996
|
+
'&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-z&state=s'
|
|
997
|
+
|
|
998
|
+
it('defaults to via-claude mode: spawns bare `claude` (NOT `claude setup-token`) and reports mode', async () => {
|
|
999
|
+
const mock = makeMockTmuxOps({ captureResponses: [BROAD_URL] })
|
|
1000
|
+
const result = await startAccountAuthSession('broad@example.com', {
|
|
1001
|
+
home: workspace,
|
|
1002
|
+
tmuxOps: mock,
|
|
1003
|
+
urlTimeoutMs: 3_000,
|
|
1004
|
+
})
|
|
1005
|
+
expect(result.mode).toBe('via-claude')
|
|
1006
|
+
// cmd must be the bare login picker, not setup-token (setup-token mints
|
|
1007
|
+
// only user:inference, which server: agents refuse).
|
|
1008
|
+
expect(mock.newSessionCalls[0].cmd).toBe('claude')
|
|
1009
|
+
expect(mock.newSessionCalls[0].cmd).not.toMatch(/setup-token/)
|
|
1010
|
+
cleanScratchDir(result.scratchDir)
|
|
1011
|
+
})
|
|
1012
|
+
|
|
1013
|
+
it('dispatches the pre-paste picker choreography (theme + login-method Enter) via sendKey before the URL', async () => {
|
|
1014
|
+
// Pane walks: theme picker → login-method picker → URL. Each fires one
|
|
1015
|
+
// Enter via sendKey (bare key, never the literal-code `send`).
|
|
1016
|
+
const mock = makeMockTmuxOps({
|
|
1017
|
+
captureResponses: [
|
|
1018
|
+
'Choose the text style that looks best with your terminal',
|
|
1019
|
+
'Select login method:\n 1. Claude account with subscription',
|
|
1020
|
+
BROAD_URL,
|
|
1021
|
+
],
|
|
1022
|
+
})
|
|
1023
|
+
const result = await startAccountAuthSession('picker@example.com', {
|
|
1024
|
+
home: workspace,
|
|
1025
|
+
tmuxOps: mock,
|
|
1026
|
+
urlTimeoutMs: 5_000,
|
|
1027
|
+
})
|
|
1028
|
+
// Two pre-paste Enters dispatched via sendKey, both bare "Enter".
|
|
1029
|
+
expect(mock.sendKeyCalls.map((c) => c.key)).toEqual(['Enter', 'Enter'])
|
|
1030
|
+
// The literal-code `send` (which would echo a secret) is NOT used pre-paste.
|
|
1031
|
+
expect(mock.sendCalls).toHaveLength(0)
|
|
1032
|
+
expect(result.loginUrl).toMatch(/^https:\/\/claude\.com\/cai\/oauth\/authorize\?/)
|
|
1033
|
+
cleanScratchDir(result.scratchDir)
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
it('surfaces both URL shapes the picker can render (claude.ai/ and claude.com/cai/)', async () => {
|
|
1037
|
+
for (const url of [
|
|
1038
|
+
'https://claude.ai/oauth/authorize?client_id=x&code=true&scope=user%3Aprofile',
|
|
1039
|
+
BROAD_URL,
|
|
1040
|
+
]) {
|
|
1041
|
+
const mock = makeMockTmuxOps({ captureResponses: [`\x1b[0m${url}\nPaste code here:\n`] })
|
|
1042
|
+
const result = await startAccountAuthSession('urlshape', {
|
|
1043
|
+
home: workspace,
|
|
1044
|
+
tmuxOps: mock,
|
|
1045
|
+
urlTimeoutMs: 3_000,
|
|
1046
|
+
})
|
|
1047
|
+
expect(result.loginUrl).toContain('/oauth/authorize?')
|
|
1048
|
+
expect(result.loginUrl).not.toMatch(/\s/) // line-wrap collapsed
|
|
1049
|
+
cleanScratchDir(result.scratchDir)
|
|
1050
|
+
}
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
it('setup-token mode is still available and uses `claude setup-token` with no picker dispatch', async () => {
|
|
1054
|
+
const url =
|
|
1055
|
+
'https://claude.com/cai/oauth/authorize?code=true&client_id=y&response_type=code&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-z'
|
|
1056
|
+
const mock = makeMockTmuxOps({ captureResponses: [url] })
|
|
1057
|
+
const result = await startAccountAuthSession('narrow@example.com', {
|
|
1058
|
+
home: workspace,
|
|
1059
|
+
tmuxOps: mock,
|
|
1060
|
+
urlTimeoutMs: 3_000,
|
|
1061
|
+
mode: 'setup-token',
|
|
1062
|
+
})
|
|
1063
|
+
expect(result.mode).toBe('setup-token')
|
|
1064
|
+
expect(mock.newSessionCalls[0].cmd).toBe('claude setup-token')
|
|
1065
|
+
expect(mock.sendKeyCalls).toHaveLength(0) // no picker choreography
|
|
1066
|
+
cleanScratchDir(result.scratchDir)
|
|
1067
|
+
})
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
describe('submitAccountAuthCode — via-claude broad-scope + blind post-paste Enter (PR B)', () => {
|
|
1071
|
+
function makeFlow(scratchDir: string, mode: 'via-claude' | 'setup-token'): PendingAuthAddFlow {
|
|
1072
|
+
return {
|
|
1073
|
+
label: 'broad@example.com',
|
|
1074
|
+
scratchDir,
|
|
1075
|
+
tmuxSocket: 'switchroom-test',
|
|
1076
|
+
tmuxSession: 'auth-add-broad-abc123',
|
|
1077
|
+
startedAt: Date.now(),
|
|
1078
|
+
mode,
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
it('returns the minted broad scopes (user:profile present) and NEVER captures after the code', async () => {
|
|
1083
|
+
const scratchDir = mkdtempSync(join(workspace, 'broad-'))
|
|
1084
|
+
const credPath = join(scratchDir, '.credentials.json')
|
|
1085
|
+
const broadCreds = JSON.stringify({
|
|
1086
|
+
claudeAiOauth: {
|
|
1087
|
+
accessToken: 'sk-ant-oat01-test-' + 'e'.repeat(40),
|
|
1088
|
+
refreshToken: 'sk-ant-ort01-test',
|
|
1089
|
+
expiresAt: Date.now() + 8 * 3600_000,
|
|
1090
|
+
scopes: [
|
|
1091
|
+
'org:create_api_key',
|
|
1092
|
+
'user:profile',
|
|
1093
|
+
'user:inference',
|
|
1094
|
+
'user:sessions:claude_code',
|
|
1095
|
+
'user:mcp_servers',
|
|
1096
|
+
'user:file_upload',
|
|
1097
|
+
],
|
|
1098
|
+
subscriptionType: 'max',
|
|
1099
|
+
},
|
|
1100
|
+
})
|
|
1101
|
+
const mock = makeMockTmuxOps({ initialSessionAlive: true })
|
|
1102
|
+
let sendCalled = false
|
|
1103
|
+
let captureAfterSend = false
|
|
1104
|
+
mock.onSend = () => {
|
|
1105
|
+
sendCalled = true
|
|
1106
|
+
writeFileSync(credPath, broadCreds, 'utf8')
|
|
1107
|
+
}
|
|
1108
|
+
mock.onCapture = () => { if (sendCalled) captureAfterSend = true }
|
|
1109
|
+
|
|
1110
|
+
const creds = await submitAccountAuthCode(makeFlow(scratchDir, 'via-claude'), 'browser-code-xyz', {
|
|
1111
|
+
pollIntervalMs: 20,
|
|
1112
|
+
pollTimeoutMs: 3_000,
|
|
1113
|
+
tmuxOps: mock,
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
// The actual mission outcome: user:profile made it through.
|
|
1117
|
+
expect(creds.claudeAiOauth.scopes).toContain('user:profile')
|
|
1118
|
+
expect(creds.claudeAiOauth.scopes).toEqual(
|
|
1119
|
+
expect.arrayContaining(['org:create_api_key', 'user:profile', 'user:inference']),
|
|
1120
|
+
)
|
|
1121
|
+
// Invariant preserved: no capture-pane after the code paste.
|
|
1122
|
+
expect(captureAfterSend).toBe(false)
|
|
1123
|
+
expect(mock.captureCallCount).toBe(0)
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
it('dispatches BLIND Enter key-presses after the code (no capture) to clear picker screens', async () => {
|
|
1127
|
+
const scratchDir = mkdtempSync(join(workspace, 'blind-'))
|
|
1128
|
+
const credPath = join(scratchDir, '.credentials.json')
|
|
1129
|
+
const mock = makeMockTmuxOps({ initialSessionAlive: true })
|
|
1130
|
+
// Materialise creds only AFTER the blind Enters have had a chance to fire
|
|
1131
|
+
// (mimics a picker screen Enter-gating the credentials flush).
|
|
1132
|
+
let ticks = 0
|
|
1133
|
+
mock.onSend = () => {
|
|
1134
|
+
// schedule cred write a few polls later via the poll loop below
|
|
1135
|
+
ticks = 0
|
|
1136
|
+
}
|
|
1137
|
+
// Use a tight blind-enter schedule so the test is fast.
|
|
1138
|
+
const submitP = submitAccountAuthCode(makeFlow(scratchDir, 'via-claude'), 'code-abc', {
|
|
1139
|
+
pollIntervalMs: 10,
|
|
1140
|
+
pollTimeoutMs: 3_000,
|
|
1141
|
+
tmuxOps: mock,
|
|
1142
|
+
blindEnterDelaysMs: [15, 30],
|
|
1143
|
+
})
|
|
1144
|
+
// Write creds after the blind Enters would have fired.
|
|
1145
|
+
const timer = setInterval(() => {
|
|
1146
|
+
ticks++
|
|
1147
|
+
if (ticks >= 6) {
|
|
1148
|
+
writeFileSync(credPath, JSON.stringify({
|
|
1149
|
+
claudeAiOauth: { accessToken: 'sk-ant-oat01-test-' + 'f'.repeat(40), scopes: ['user:profile'] },
|
|
1150
|
+
}), 'utf8')
|
|
1151
|
+
clearInterval(timer)
|
|
1152
|
+
}
|
|
1153
|
+
}, 10)
|
|
1154
|
+
const creds = await submitP
|
|
1155
|
+
clearInterval(timer)
|
|
1156
|
+
// Blind Enters were dispatched via sendKey, and capture-pane never was.
|
|
1157
|
+
expect(mock.sendKeyCalls.every((c) => c.key === 'Enter')).toBe(true)
|
|
1158
|
+
expect(mock.sendKeyCalls.length).toBeGreaterThanOrEqual(1)
|
|
1159
|
+
expect(mock.captureCallCount).toBe(0)
|
|
1160
|
+
expect(creds.claudeAiOauth.scopes).toContain('user:profile')
|
|
1161
|
+
})
|
|
1162
|
+
|
|
1163
|
+
it('setup-token mode dispatches NO blind Enters (session exits on its own)', async () => {
|
|
1164
|
+
const scratchDir = mkdtempSync(join(workspace, 'st-'))
|
|
1165
|
+
const credPath = join(scratchDir, '.credentials.json')
|
|
1166
|
+
const mock = makeMockTmuxOps({ initialSessionAlive: true })
|
|
1167
|
+
mock.onSend = () => {
|
|
1168
|
+
writeFileSync(credPath, JSON.stringify({
|
|
1169
|
+
claudeAiOauth: { accessToken: 'sk-ant-oat01-test-' + 'g'.repeat(40), scopes: ['user:inference'] },
|
|
1170
|
+
}), 'utf8')
|
|
1171
|
+
}
|
|
1172
|
+
await submitAccountAuthCode(makeFlow(scratchDir, 'setup-token'), 'code', {
|
|
1173
|
+
pollIntervalMs: 20,
|
|
1174
|
+
pollTimeoutMs: 2_000,
|
|
1175
|
+
tmuxOps: mock,
|
|
1176
|
+
})
|
|
1177
|
+
expect(mock.sendKeyCalls).toHaveLength(0)
|
|
1178
|
+
})
|
|
1179
|
+
})
|
|
1180
|
+
|
|
819
1181
|
/* ── helpers ─────────────────────────────────────────────────────────── */
|
|
820
1182
|
|
|
821
1183
|
function randomHex(): string {
|
|
@@ -1199,3 +1199,44 @@ describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demo
|
|
|
1199
1199
|
expect(out).not.toContain('All accounts blocked');
|
|
1200
1200
|
});
|
|
1201
1201
|
});
|
|
1202
|
+
|
|
1203
|
+
// ── retired / org-blocked (in-service classification, PR1) ────────────
|
|
1204
|
+
describe('classifyHealth — retired / org-blocked precedence', () => {
|
|
1205
|
+
// A retired/org-disabled account must NEVER read as available: config and
|
|
1206
|
+
// entitlement outrank the quota windows for any non-active account.
|
|
1207
|
+
const healthyQ = quota({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 });
|
|
1208
|
+
|
|
1209
|
+
it('non-active account with inService:false → retired, even on healthy quota', () => {
|
|
1210
|
+
expect(classifyHealth(snap({ inService: false, quota: healthyQ }))).toBe('retired');
|
|
1211
|
+
});
|
|
1212
|
+
it('non-active account with entitlementBlocked → org-blocked', () => {
|
|
1213
|
+
expect(classifyHealth(snap({ entitlementBlocked: true, quota: healthyQ }))).toBe('org-blocked');
|
|
1214
|
+
});
|
|
1215
|
+
it('entitlement block outranks retirement', () => {
|
|
1216
|
+
expect(
|
|
1217
|
+
classifyHealth(snap({ inService: false, entitlementBlocked: true, quota: healthyQ })),
|
|
1218
|
+
).toBe('org-blocked');
|
|
1219
|
+
});
|
|
1220
|
+
it('absent inService defaults to in-service (a pre-field broker is never falsely retired)', () => {
|
|
1221
|
+
expect(classifyHealth(snap({ quota: healthyQ }))).toBe('healthy');
|
|
1222
|
+
});
|
|
1223
|
+
it('the active account keeps its quota-derived health even if inService is false', () => {
|
|
1224
|
+
// active is in-service by definition; the guard skips retirement for it.
|
|
1225
|
+
expect(classifyHealth(snap({ isActive: true, inService: false, quota: healthyQ }))).toBe('healthy');
|
|
1226
|
+
});
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
describe('recommendation — excludes retired/org-blocked accounts', () => {
|
|
1230
|
+
it('a retired account (even with healthy quota) is never offered as a switch target', () => {
|
|
1231
|
+
// active is throttling; the only OTHER account is retired but at 0% util.
|
|
1232
|
+
// Without the exclusion it would be recommended as a healthy switch target;
|
|
1233
|
+
// with it, the retired account is invisible → "no healthy alternative".
|
|
1234
|
+
const snaps: AccountSnapshot[] = [
|
|
1235
|
+
snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 90 }) }),
|
|
1236
|
+
snap({ label: 'retired@x', inService: false, quota: quota({ fiveHourUtilizationPct: 0 }) }),
|
|
1237
|
+
];
|
|
1238
|
+
const out = recommendation(snaps, NOW);
|
|
1239
|
+
expect(out).not.toContain('retired@x');
|
|
1240
|
+
expect(out).toContain('no healthy alternative');
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for External OpenRouter/$ spend (layout B on /usage).
|
|
3
|
+
* Pure filter/format coverage lives primarily in
|
|
4
|
+
* `src/litellm/external-spend.test.ts`. This file covers the card
|
|
5
|
+
* block formatter + broker-backed fetch ExternalSpendSummary path.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
8
|
+
import {
|
|
9
|
+
formatExternalSpendBlock,
|
|
10
|
+
fetchExternalSpendSummary,
|
|
11
|
+
clearExternalSpendCache,
|
|
12
|
+
formatUsd,
|
|
13
|
+
isExternalModel,
|
|
14
|
+
shortModelLabel,
|
|
15
|
+
summarizeExternalSpend,
|
|
16
|
+
} from '../external-spend.js';
|
|
17
|
+
import { renderUsageCard } from '../quota-bar-format.js';
|
|
18
|
+
import type { AccountSnapshot } from '../auth-snapshot-format.js';
|
|
19
|
+
import type { QuotaUtilization } from '../quota-check.js';
|
|
20
|
+
|
|
21
|
+
const NOW = new Date('2026-07-19T15:30:00.000Z');
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
clearExternalSpendCache();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('re-exported pure helpers', () => {
|
|
28
|
+
it('filter / labels / money still work from the telegram module', () => {
|
|
29
|
+
expect(isExternalModel('openrouter/openai/gpt-oss-20b')).toBe(true);
|
|
30
|
+
expect(isExternalModel('claude-sonnet-5')).toBe(false);
|
|
31
|
+
expect(shortModelLabel('openrouter/x-ai/grok-4.5')).toBe('grok-4.5');
|
|
32
|
+
expect(formatUsd(8.07)).toBe('$8.07');
|
|
33
|
+
const s = summarizeExternalSpend(
|
|
34
|
+
[
|
|
35
|
+
{
|
|
36
|
+
startTime: '2026-07-19',
|
|
37
|
+
models: { 'openrouter/openai/gpt-oss-20b': 1.5, 'claude-sonnet-5': 9 },
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
NOW,
|
|
41
|
+
);
|
|
42
|
+
expect(s.day24hUsd).toBeCloseTo(1.5, 5);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('formatExternalSpendBlock', () => {
|
|
47
|
+
it('renders locked layout B', () => {
|
|
48
|
+
expect(
|
|
49
|
+
formatExternalSpendBlock({
|
|
50
|
+
day24hUsd: 8.07,
|
|
51
|
+
day7dUsd: 119.89,
|
|
52
|
+
top: [
|
|
53
|
+
{ label: 'gpt-oss-20b', usd: 6.1 },
|
|
54
|
+
{ label: 'grok-4.5', usd: 1.18 },
|
|
55
|
+
{ label: 'gemini-3.1-flash-lite', usd: 0.79 },
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
).toEqual([
|
|
59
|
+
'- 💸 External',
|
|
60
|
+
'- 24h `$8.07` · 7d `$119.89`',
|
|
61
|
+
'- top `gpt-oss-20b $6.10` · `grok-4.5 $1.18` · `gemini-3.1-flash-lite $0.79`',
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('omits top and returns [] for null', () => {
|
|
66
|
+
expect(formatExternalSpendBlock({ day24hUsd: 0, day7dUsd: 0, top: [] })).toEqual([
|
|
67
|
+
'- 💸 External',
|
|
68
|
+
'- 24h `$0.00` · 7d `$0.00`',
|
|
69
|
+
]);
|
|
70
|
+
expect(formatExternalSpendBlock(null)).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('fetchExternalSpendSummary (broker path)', () => {
|
|
75
|
+
it('returns null when broker says unavailable', async () => {
|
|
76
|
+
const s = await fetchExternalSpendSummary({
|
|
77
|
+
getExternalSpend: async () => ({ available: false, reason: 'master_key_unavailable' }),
|
|
78
|
+
bypassCache: true,
|
|
79
|
+
});
|
|
80
|
+
expect(s).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('maps available broker payload + caches', async () => {
|
|
84
|
+
let calls = 0;
|
|
85
|
+
const getExternalSpend = vi.fn(async () => {
|
|
86
|
+
calls += 1;
|
|
87
|
+
return {
|
|
88
|
+
available: true,
|
|
89
|
+
day24hUsd: 1.5,
|
|
90
|
+
day7dUsd: 10,
|
|
91
|
+
top: [{ label: 'gpt-oss-20b', usd: 1.5 }],
|
|
92
|
+
served: 'live' as const,
|
|
93
|
+
capturedAtMs: Date.now(),
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
const first = await fetchExternalSpendSummary({
|
|
97
|
+
getExternalSpend,
|
|
98
|
+
bypassCache: true,
|
|
99
|
+
});
|
|
100
|
+
expect(first).toEqual({
|
|
101
|
+
day24hUsd: 1.5,
|
|
102
|
+
day7dUsd: 10,
|
|
103
|
+
top: [{ label: 'gpt-oss-20b', usd: 1.5 }],
|
|
104
|
+
});
|
|
105
|
+
const second = await fetchExternalSpendSummary({ getExternalSpend });
|
|
106
|
+
expect(second?.day24hUsd).toBeCloseTo(1.5, 5);
|
|
107
|
+
expect(calls).toBe(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns null on thrown broker error', async () => {
|
|
111
|
+
const s = await fetchExternalSpendSummary({
|
|
112
|
+
getExternalSpend: async () => {
|
|
113
|
+
throw new Error('unreachable');
|
|
114
|
+
},
|
|
115
|
+
bypassCache: true,
|
|
116
|
+
});
|
|
117
|
+
expect(s).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('renderUsageCard + externalSpend', () => {
|
|
122
|
+
function quota(part: Partial<QuotaUtilization>): QuotaUtilization {
|
|
123
|
+
return {
|
|
124
|
+
fiveHourUtilizationPct: 0,
|
|
125
|
+
sevenDayUtilizationPct: 0,
|
|
126
|
+
fiveHourResetAt: null,
|
|
127
|
+
sevenDayResetAt: null,
|
|
128
|
+
representativeClaim: null,
|
|
129
|
+
overageStatus: null,
|
|
130
|
+
overageDisabledReason: null,
|
|
131
|
+
...part,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const snapshots: AccountSnapshot[] = [
|
|
135
|
+
{
|
|
136
|
+
label: 'ken@example.com',
|
|
137
|
+
isActive: true,
|
|
138
|
+
quota: quota({
|
|
139
|
+
fiveHourUtilizationPct: 0,
|
|
140
|
+
sevenDayUtilizationPct: 47,
|
|
141
|
+
fiveHourResetAt: new Date(NOW.getTime() + 60 * 60_000),
|
|
142
|
+
sevenDayResetAt: new Date(NOW.getTime() + (3 * 24 + 1) * 60 * 60_000),
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
const exhausted = new Map<string, boolean>([['ken@example.com', false]]);
|
|
147
|
+
|
|
148
|
+
it('inserts External between recommendation and freshness', () => {
|
|
149
|
+
const out = renderUsageCard(snapshots, exhausted, {
|
|
150
|
+
now: NOW,
|
|
151
|
+
externalSpend: {
|
|
152
|
+
day24hUsd: 8.07,
|
|
153
|
+
day7dUsd: 119.89,
|
|
154
|
+
top: [{ label: 'gpt-oss-20b', usd: 6.1 }],
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
const lines = out.split('\n');
|
|
158
|
+
const recIdx = lines.findIndex((l) => l.includes('Recommendation'));
|
|
159
|
+
const extIdx = lines.findIndex((l) => l.includes('💸 External'));
|
|
160
|
+
expect(extIdx).toBe(recIdx + 1);
|
|
161
|
+
expect(lines[lines.length - 1]).toBe('_Live_');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('omits External when null', () => {
|
|
165
|
+
const out = renderUsageCard(snapshots, exhausted, { now: NOW, externalSpend: null });
|
|
166
|
+
expect(out).not.toContain('💸 External');
|
|
167
|
+
});
|
|
168
|
+
});
|