switchroom 0.8.1 → 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 +49 -57
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +285 -45
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/switchroom.js +15931 -12778
- package/dist/host-control/main.js +582 -43
- package/dist/vault/approvals/kernel-server.js +276 -47
- package/dist/vault/broker/server.js +333 -69
- 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 +6 -4
- package/profiles/_base/start.sh.hbs +3 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/default/CLAUDE.md +10 -0
- package/profiles/default/CLAUDE.md.hbs +16 -0
- 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/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 +67 -15
- package/skills/switchroom-status/SKILL.md +26 -1
- package/skills/telegram-test-harness/SKILL.md +3 -0
- 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/dist/gateway/gateway.js +13042 -12844
- 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 +22 -36
- package/telegram-plugin/gateway/boot-probes.ts +3 -3
- package/telegram-plugin/gateway/gateway.ts +313 -798
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -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-probes.test.ts +11 -4
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/uat/SETUP.md +31 -1
- 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/jtbd-interrupt-marker-dm.test.ts +7 -1
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/dist/foreman/foreman.js +0 -31358
- 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/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/boot-card-account-quota.test.ts +0 -137
- 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/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/auth` CLI-vernacular alignment coverage (RFC H Decision 11 —
|
|
3
|
+
* "same shape on the CLI and in Telegram").
|
|
4
|
+
*
|
|
5
|
+
* Pins the post-/auth-add verb tree that mirrors `switchroom auth`:
|
|
6
|
+
*
|
|
7
|
+
* list / show [<agent>] / rm <label> [confirm] / refresh [<label>]
|
|
8
|
+
* / agent override <agent> <label|clear> / help
|
|
9
|
+
*
|
|
10
|
+
* The headline guarantees:
|
|
11
|
+
*
|
|
12
|
+
* 1. Every verb resolves through the pure parser to the right
|
|
13
|
+
* ParsedAuthCommand kind (no I/O in `parseAuthCommand`).
|
|
14
|
+
* 2. Read verbs (`show`, `list`, `show <agent>`, `help`) are open
|
|
15
|
+
* to any agent; mutating verbs are admin-gated.
|
|
16
|
+
* 3. The `rm` two-step confirm is paired by chat id + label and
|
|
17
|
+
* respects the 60s TTL.
|
|
18
|
+
* 4. `rm` refuses to even prompt when the label is the fleet active
|
|
19
|
+
* (broker enforces too, but the chat surface short-circuits for
|
|
20
|
+
* a cleaner error).
|
|
21
|
+
* 5. `refresh` (no label) iterates every known account, once each.
|
|
22
|
+
* 6. `override` set vs clear translates the chat-ergonomic `clear`
|
|
23
|
+
* keyword to a `null` broker argument.
|
|
24
|
+
* 7. Help text lists every verb (string-contains).
|
|
25
|
+
*
|
|
26
|
+
* Sibling to `auth-add-flow.test.ts` — keeps the new surface's tests
|
|
27
|
+
* scoped to a dedicated file rather than ballooning that one further.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
parseAuthCommand,
|
|
34
|
+
handleAuthCommand,
|
|
35
|
+
pendingAuthRmFlows,
|
|
36
|
+
AUTH_RM_CONFIRM_TTL_MS,
|
|
37
|
+
type AuthBrokerClient,
|
|
38
|
+
type ListStateData,
|
|
39
|
+
} from '../gateway/auth-command.js'
|
|
40
|
+
|
|
41
|
+
/* ── Fixture builders ─────────────────────────────────────────────────── */
|
|
42
|
+
|
|
43
|
+
function fakeState(over: Partial<ListStateData> = {}): ListStateData {
|
|
44
|
+
return {
|
|
45
|
+
active: 'primary',
|
|
46
|
+
fallback_order: ['primary', 'spare'],
|
|
47
|
+
accounts: [
|
|
48
|
+
{
|
|
49
|
+
label: 'primary',
|
|
50
|
+
expiresAt: Date.now() + 6 * 3600_000,
|
|
51
|
+
exhausted: false,
|
|
52
|
+
last_refreshed_at: Date.now() - 600_000,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
label: 'spare',
|
|
56
|
+
expiresAt: Date.now() + 4 * 3600_000,
|
|
57
|
+
exhausted: false,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
agents: [
|
|
61
|
+
{ name: 'clerk', account: 'primary', override: null },
|
|
62
|
+
{ name: 'researcher', account: 'spare', override: 'spare' },
|
|
63
|
+
],
|
|
64
|
+
consumers: [],
|
|
65
|
+
...over,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface MockClient extends AuthBrokerClient {
|
|
70
|
+
listState: ReturnType<typeof vi.fn>
|
|
71
|
+
setActive: ReturnType<typeof vi.fn>
|
|
72
|
+
rmAccount: ReturnType<typeof vi.fn>
|
|
73
|
+
refreshAccount: ReturnType<typeof vi.fn>
|
|
74
|
+
setOverride: ReturnType<typeof vi.fn>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function mockClient(state: ListStateData = fakeState()): MockClient {
|
|
78
|
+
return {
|
|
79
|
+
listState: vi.fn().mockResolvedValue(state),
|
|
80
|
+
setActive: vi.fn().mockResolvedValue({ active: 'spare', fanned: ['clerk'] }),
|
|
81
|
+
rmAccount: vi.fn().mockImplementation(async (label: string) => ({ label })),
|
|
82
|
+
refreshAccount: vi.fn().mockImplementation(async (label: string) => ({
|
|
83
|
+
account: label,
|
|
84
|
+
expiresAt: Date.now() + 8 * 3600_000,
|
|
85
|
+
})),
|
|
86
|
+
setOverride: vi
|
|
87
|
+
.fn()
|
|
88
|
+
.mockImplementation(async (agent: string, account: string | null) => ({
|
|
89
|
+
agent,
|
|
90
|
+
account,
|
|
91
|
+
})),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
pendingAuthRmFlows.clear()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/* ── 1. Parser ────────────────────────────────────────────────────────── */
|
|
100
|
+
|
|
101
|
+
describe('parseAuthCommand — new verbs', () => {
|
|
102
|
+
it('parses /auth list as { kind: "list" }', () => {
|
|
103
|
+
expect(parseAuthCommand('/auth list')).toEqual({ kind: 'list' })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('parses /auth show <agent> as { kind: "show", agent }', () => {
|
|
107
|
+
expect(parseAuthCommand('/auth show clerk')).toEqual({
|
|
108
|
+
kind: 'show',
|
|
109
|
+
agent: 'clerk',
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('bare /auth show stays kindshow with no agent field set', () => {
|
|
114
|
+
const p = parseAuthCommand('/auth show')
|
|
115
|
+
expect(p?.kind).toBe('show')
|
|
116
|
+
expect((p as { agent?: string }).agent).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('parses /auth rm <label> as rm-prompt', () => {
|
|
120
|
+
expect(parseAuthCommand('/auth rm spare')).toEqual({
|
|
121
|
+
kind: 'rm-prompt',
|
|
122
|
+
label: 'spare',
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('parses /auth rm <label> confirm as rm-confirmed (case-insensitive)', () => {
|
|
127
|
+
expect(parseAuthCommand('/auth rm spare confirm')).toEqual({
|
|
128
|
+
kind: 'rm-confirmed',
|
|
129
|
+
label: 'spare',
|
|
130
|
+
})
|
|
131
|
+
expect(parseAuthCommand('/auth rm spare CONFIRM')).toEqual({
|
|
132
|
+
kind: 'rm-confirmed',
|
|
133
|
+
label: 'spare',
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('rejects /auth rm <label> <bogus> with a help reason', () => {
|
|
138
|
+
const p = parseAuthCommand('/auth rm spare yesplease')
|
|
139
|
+
expect(p?.kind).toBe('help')
|
|
140
|
+
expect((p as { reason?: string }).reason).toMatch(/confirm/i)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('rejects /auth rm with no label', () => {
|
|
144
|
+
const p = parseAuthCommand('/auth rm')
|
|
145
|
+
expect(p?.kind).toBe('help')
|
|
146
|
+
expect((p as { reason?: string }).reason).toMatch(/usage/i)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('parses /auth refresh (no label)', () => {
|
|
150
|
+
expect(parseAuthCommand('/auth refresh')).toEqual({ kind: 'refresh' })
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('parses /auth refresh <label>', () => {
|
|
154
|
+
expect(parseAuthCommand('/auth refresh primary')).toEqual({
|
|
155
|
+
kind: 'refresh',
|
|
156
|
+
label: 'primary',
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('parses /auth agent override <agent> <label>', () => {
|
|
161
|
+
expect(parseAuthCommand('/auth agent override clerk primary')).toEqual({
|
|
162
|
+
kind: 'override-set',
|
|
163
|
+
agent: 'clerk',
|
|
164
|
+
label: 'primary',
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('parses /auth agent override <agent> clear as override-clear', () => {
|
|
169
|
+
expect(parseAuthCommand('/auth agent override clerk clear')).toEqual({
|
|
170
|
+
kind: 'override-clear',
|
|
171
|
+
agent: 'clerk',
|
|
172
|
+
})
|
|
173
|
+
// case-insensitive
|
|
174
|
+
expect(parseAuthCommand('/auth agent override clerk CLEAR')).toEqual({
|
|
175
|
+
kind: 'override-clear',
|
|
176
|
+
agent: 'clerk',
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('rejects /auth agent override with missing args', () => {
|
|
181
|
+
const a = parseAuthCommand('/auth agent override')
|
|
182
|
+
const b = parseAuthCommand('/auth agent override clerk')
|
|
183
|
+
expect(a?.kind).toBe('help')
|
|
184
|
+
expect(b?.kind).toBe('help')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('rejects /auth agent <unknown-sub>', () => {
|
|
188
|
+
const p = parseAuthCommand('/auth agent pin clerk primary')
|
|
189
|
+
expect(p?.kind).toBe('help')
|
|
190
|
+
expect((p as { reason?: string }).reason).toMatch(/override/i)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('parses /auth help explicitly', () => {
|
|
194
|
+
expect(parseAuthCommand('/auth help')).toEqual({ kind: 'help' })
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('routes unknown verbs to help with a reason', () => {
|
|
198
|
+
const p = parseAuthCommand('/auth nonsense')
|
|
199
|
+
expect(p?.kind).toBe('help')
|
|
200
|
+
expect((p as { reason?: string }).reason).toMatch(/unknown/i)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('tolerates extra whitespace and bot-suffix', () => {
|
|
204
|
+
expect(parseAuthCommand(' /auth list ')).toEqual({ kind: 'list' })
|
|
205
|
+
expect(parseAuthCommand('/auth@switchroombot list')).toEqual({ kind: 'list' })
|
|
206
|
+
expect(parseAuthCommand('/auth\tshow\tclerk')).toEqual({
|
|
207
|
+
kind: 'show',
|
|
208
|
+
agent: 'clerk',
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('is case-insensitive on the verb', () => {
|
|
213
|
+
expect(parseAuthCommand('/auth LIST')?.kind).toBe('list')
|
|
214
|
+
expect(parseAuthCommand('/auth REFRESH')?.kind).toBe('refresh')
|
|
215
|
+
expect(parseAuthCommand('/auth Agent OVERRIDE clerk clear')).toEqual({
|
|
216
|
+
kind: 'override-clear',
|
|
217
|
+
agent: 'clerk',
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
/* ── 2. Read-verb open access ─────────────────────────────────────────── */
|
|
223
|
+
|
|
224
|
+
describe('handleAuthCommand — read verbs are open to any agent', () => {
|
|
225
|
+
it('/auth list renders the fleet snapshot without an admin gate', async () => {
|
|
226
|
+
const client = mockClient()
|
|
227
|
+
const reply = await handleAuthCommand(
|
|
228
|
+
{ kind: 'list' },
|
|
229
|
+
{ agentName: 'random-agent', isAdmin: false, client },
|
|
230
|
+
)
|
|
231
|
+
expect(reply.html).toBe(true)
|
|
232
|
+
expect(reply.text).toMatch(/Auth — fleet snapshot/)
|
|
233
|
+
expect(reply.text).not.toMatch(/Not authorized/i)
|
|
234
|
+
expect(client.listState).toHaveBeenCalledTimes(1)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('/auth show <agent> renders per-agent detail for any agent', async () => {
|
|
238
|
+
const client = mockClient()
|
|
239
|
+
const reply = await handleAuthCommand(
|
|
240
|
+
{ kind: 'show', agent: 'researcher' },
|
|
241
|
+
{ agentName: 'random', isAdmin: false, client },
|
|
242
|
+
)
|
|
243
|
+
expect(reply.text).toMatch(/researcher/)
|
|
244
|
+
expect(reply.text).toMatch(/override/)
|
|
245
|
+
expect(reply.text).toMatch(/spare/)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('/auth show <unknown-agent> returns a friendly error', async () => {
|
|
249
|
+
const client = mockClient()
|
|
250
|
+
const reply = await handleAuthCommand(
|
|
251
|
+
{ kind: 'show', agent: 'ghost' },
|
|
252
|
+
{ agentName: 'random', isAdmin: false, client },
|
|
253
|
+
)
|
|
254
|
+
expect(reply.text).toMatch(/no agent named/i)
|
|
255
|
+
expect(reply.text).toMatch(/ghost/)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
/* ── 3. Admin gating ──────────────────────────────────────────────────── */
|
|
260
|
+
|
|
261
|
+
describe('handleAuthCommand — admin gating', () => {
|
|
262
|
+
const nonAdmin = { agentName: 'snooper', isAdmin: false }
|
|
263
|
+
|
|
264
|
+
it('refuses /auth rm <label> for non-admin', async () => {
|
|
265
|
+
const client = mockClient()
|
|
266
|
+
const reply = await handleAuthCommand(
|
|
267
|
+
{ kind: 'rm-prompt', label: 'spare' },
|
|
268
|
+
{ ...nonAdmin, client },
|
|
269
|
+
)
|
|
270
|
+
expect(reply.text).toMatch(/Not authorized/i)
|
|
271
|
+
expect(client.listState).not.toHaveBeenCalled()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('refuses /auth rm <label> confirm for non-admin', async () => {
|
|
275
|
+
const client = mockClient()
|
|
276
|
+
const reply = await handleAuthCommand(
|
|
277
|
+
{ kind: 'rm-confirmed', label: 'spare' },
|
|
278
|
+
{ ...nonAdmin, client },
|
|
279
|
+
)
|
|
280
|
+
expect(reply.text).toMatch(/Not authorized/i)
|
|
281
|
+
expect(client.rmAccount).not.toHaveBeenCalled()
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('refuses /auth refresh for non-admin', async () => {
|
|
285
|
+
const client = mockClient()
|
|
286
|
+
const reply = await handleAuthCommand(
|
|
287
|
+
{ kind: 'refresh' },
|
|
288
|
+
{ ...nonAdmin, client },
|
|
289
|
+
)
|
|
290
|
+
expect(reply.text).toMatch(/Not authorized/i)
|
|
291
|
+
expect(client.refreshAccount).not.toHaveBeenCalled()
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('refuses /auth agent override <set> for non-admin', async () => {
|
|
295
|
+
const client = mockClient()
|
|
296
|
+
const reply = await handleAuthCommand(
|
|
297
|
+
{ kind: 'override-set', agent: 'clerk', label: 'spare' },
|
|
298
|
+
{ ...nonAdmin, client },
|
|
299
|
+
)
|
|
300
|
+
expect(reply.text).toMatch(/Not authorized/i)
|
|
301
|
+
expect(client.setOverride).not.toHaveBeenCalled()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('refuses /auth agent override <clear> for non-admin', async () => {
|
|
305
|
+
const client = mockClient()
|
|
306
|
+
const reply = await handleAuthCommand(
|
|
307
|
+
{ kind: 'override-clear', agent: 'clerk' },
|
|
308
|
+
{ ...nonAdmin, client },
|
|
309
|
+
)
|
|
310
|
+
expect(reply.text).toMatch(/Not authorized/i)
|
|
311
|
+
expect(client.setOverride).not.toHaveBeenCalled()
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
/* ── 4. rm two-step confirm flow ──────────────────────────────────────── */
|
|
316
|
+
|
|
317
|
+
describe('handleAuthCommand — /auth rm two-step confirm', () => {
|
|
318
|
+
const admin = { agentName: 'clerk', isAdmin: true }
|
|
319
|
+
|
|
320
|
+
it('prompt phase succeeds for a valid non-active label and stashes a pending entry', async () => {
|
|
321
|
+
const client = mockClient()
|
|
322
|
+
const reply = await handleAuthCommand(
|
|
323
|
+
{ kind: 'rm-prompt', label: 'spare' },
|
|
324
|
+
{ ...admin, client, chatId: '999' },
|
|
325
|
+
)
|
|
326
|
+
expect(reply.text).toMatch(/about to remove/i)
|
|
327
|
+
expect(reply.text).toMatch(/spare/)
|
|
328
|
+
expect(reply.text).toMatch(/confirm/i)
|
|
329
|
+
expect(pendingAuthRmFlows.get('999')?.label).toBe('spare')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('refuses to prompt when the label is unknown', async () => {
|
|
333
|
+
const client = mockClient()
|
|
334
|
+
const reply = await handleAuthCommand(
|
|
335
|
+
{ kind: 'rm-prompt', label: 'doesnotexist' },
|
|
336
|
+
{ ...admin, client, chatId: '999' },
|
|
337
|
+
)
|
|
338
|
+
expect(reply.text).toMatch(/no account named/i)
|
|
339
|
+
expect(client.rmAccount).not.toHaveBeenCalled()
|
|
340
|
+
expect(pendingAuthRmFlows.size).toBe(0)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('refuses to prompt when the label is the fleet active', async () => {
|
|
344
|
+
const client = mockClient()
|
|
345
|
+
const reply = await handleAuthCommand(
|
|
346
|
+
{ kind: 'rm-prompt', label: 'primary' },
|
|
347
|
+
{ ...admin, client, chatId: '999' },
|
|
348
|
+
)
|
|
349
|
+
expect(reply.text).toMatch(/fleet active/i)
|
|
350
|
+
expect(reply.text).toMatch(/use/)
|
|
351
|
+
expect(client.rmAccount).not.toHaveBeenCalled()
|
|
352
|
+
expect(pendingAuthRmFlows.size).toBe(0)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('confirm phase only fires when a matching pending entry exists', async () => {
|
|
356
|
+
const client = mockClient()
|
|
357
|
+
// Phase 1
|
|
358
|
+
await handleAuthCommand(
|
|
359
|
+
{ kind: 'rm-prompt', label: 'spare' },
|
|
360
|
+
{ ...admin, client, chatId: 'C' },
|
|
361
|
+
)
|
|
362
|
+
// Phase 2
|
|
363
|
+
const reply = await handleAuthCommand(
|
|
364
|
+
{ kind: 'rm-confirmed', label: 'spare' },
|
|
365
|
+
{ ...admin, client, chatId: 'C' },
|
|
366
|
+
)
|
|
367
|
+
expect(reply.text).toMatch(/Removed/i)
|
|
368
|
+
expect(client.rmAccount).toHaveBeenCalledTimes(1)
|
|
369
|
+
expect(client.rmAccount).toHaveBeenCalledWith('spare')
|
|
370
|
+
expect(pendingAuthRmFlows.has('C')).toBe(false)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('confirm refuses when no prompt was issued', async () => {
|
|
374
|
+
const client = mockClient()
|
|
375
|
+
const reply = await handleAuthCommand(
|
|
376
|
+
{ kind: 'rm-confirmed', label: 'spare' },
|
|
377
|
+
{ ...admin, client, chatId: 'C' },
|
|
378
|
+
)
|
|
379
|
+
expect(reply.text).toMatch(/no pending confirm/i)
|
|
380
|
+
expect(client.rmAccount).not.toHaveBeenCalled()
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('confirm refuses when the pending label does not match', async () => {
|
|
384
|
+
const client = mockClient()
|
|
385
|
+
pendingAuthRmFlows.set('C', {
|
|
386
|
+
label: 'other-label',
|
|
387
|
+
expiresAt: Date.now() + AUTH_RM_CONFIRM_TTL_MS,
|
|
388
|
+
})
|
|
389
|
+
const reply = await handleAuthCommand(
|
|
390
|
+
{ kind: 'rm-confirmed', label: 'spare' },
|
|
391
|
+
{ ...admin, client, chatId: 'C' },
|
|
392
|
+
)
|
|
393
|
+
expect(reply.text).toMatch(/no pending confirm/i)
|
|
394
|
+
expect(client.rmAccount).not.toHaveBeenCalled()
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('confirm refuses when the pending entry has expired', async () => {
|
|
398
|
+
const client = mockClient()
|
|
399
|
+
pendingAuthRmFlows.set('C', {
|
|
400
|
+
label: 'spare',
|
|
401
|
+
expiresAt: Date.now() - 1, // expired
|
|
402
|
+
})
|
|
403
|
+
const reply = await handleAuthCommand(
|
|
404
|
+
{ kind: 'rm-confirmed', label: 'spare' },
|
|
405
|
+
{ ...admin, client, chatId: 'C' },
|
|
406
|
+
)
|
|
407
|
+
expect(reply.text).toMatch(/expired|no pending confirm/i)
|
|
408
|
+
expect(client.rmAccount).not.toHaveBeenCalled()
|
|
409
|
+
// Stale entry should be reaped.
|
|
410
|
+
expect(pendingAuthRmFlows.has('C')).toBe(false)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('TTL is the documented 60 seconds', () => {
|
|
414
|
+
expect(AUTH_RM_CONFIRM_TTL_MS).toBe(60_000)
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
/* ── 5. refresh ───────────────────────────────────────────────────────── */
|
|
419
|
+
|
|
420
|
+
describe('handleAuthCommand — /auth refresh', () => {
|
|
421
|
+
const admin = { agentName: 'clerk', isAdmin: true }
|
|
422
|
+
|
|
423
|
+
it('without a label refreshes every account, once each', async () => {
|
|
424
|
+
const client = mockClient()
|
|
425
|
+
const reply = await handleAuthCommand(
|
|
426
|
+
{ kind: 'refresh' },
|
|
427
|
+
{ ...admin, client },
|
|
428
|
+
)
|
|
429
|
+
expect(reply.text).toMatch(/Refreshed/)
|
|
430
|
+
expect(client.refreshAccount).toHaveBeenCalledTimes(2)
|
|
431
|
+
expect(client.refreshAccount).toHaveBeenCalledWith('primary')
|
|
432
|
+
expect(client.refreshAccount).toHaveBeenCalledWith('spare')
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('with a label refreshes that account once', async () => {
|
|
436
|
+
const client = mockClient()
|
|
437
|
+
const reply = await handleAuthCommand(
|
|
438
|
+
{ kind: 'refresh', label: 'spare' },
|
|
439
|
+
{ ...admin, client },
|
|
440
|
+
)
|
|
441
|
+
expect(reply.text).toMatch(/Refreshed/)
|
|
442
|
+
expect(reply.text).toMatch(/spare/)
|
|
443
|
+
expect(client.refreshAccount).toHaveBeenCalledTimes(1)
|
|
444
|
+
expect(client.refreshAccount).toHaveBeenCalledWith('spare')
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('with an unknown label returns a friendly error and does not call the broker', async () => {
|
|
448
|
+
const client = mockClient()
|
|
449
|
+
const reply = await handleAuthCommand(
|
|
450
|
+
{ kind: 'refresh', label: 'ghost' },
|
|
451
|
+
{ ...admin, client },
|
|
452
|
+
)
|
|
453
|
+
expect(reply.text).toMatch(/no account named/i)
|
|
454
|
+
expect(client.refreshAccount).not.toHaveBeenCalled()
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('reports per-account failures without aborting the whole sweep', async () => {
|
|
458
|
+
const client = mockClient()
|
|
459
|
+
client.refreshAccount.mockImplementation(async (label: string) => {
|
|
460
|
+
if (label === 'primary') throw new Error('rate-limited')
|
|
461
|
+
return { account: label, expiresAt: Date.now() + 1000 }
|
|
462
|
+
})
|
|
463
|
+
const reply = await handleAuthCommand(
|
|
464
|
+
{ kind: 'refresh' },
|
|
465
|
+
{ ...admin, client },
|
|
466
|
+
)
|
|
467
|
+
expect(client.refreshAccount).toHaveBeenCalledTimes(2)
|
|
468
|
+
expect(reply.text).toMatch(/Failures/i)
|
|
469
|
+
expect(reply.text).toMatch(/rate-limited/)
|
|
470
|
+
})
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
/* ── 6. override set + clear ──────────────────────────────────────────── */
|
|
474
|
+
|
|
475
|
+
describe('handleAuthCommand — /auth agent override', () => {
|
|
476
|
+
const admin = { agentName: 'clerk', isAdmin: true }
|
|
477
|
+
|
|
478
|
+
it('set calls setOverride(agent, label)', async () => {
|
|
479
|
+
const client = mockClient()
|
|
480
|
+
const reply = await handleAuthCommand(
|
|
481
|
+
{ kind: 'override-set', agent: 'researcher', label: 'primary' },
|
|
482
|
+
{ ...admin, client },
|
|
483
|
+
)
|
|
484
|
+
expect(client.setOverride).toHaveBeenCalledTimes(1)
|
|
485
|
+
expect(client.setOverride).toHaveBeenCalledWith('researcher', 'primary')
|
|
486
|
+
expect(reply.text).toMatch(/Override set/i)
|
|
487
|
+
expect(reply.text).toMatch(/researcher/)
|
|
488
|
+
expect(reply.text).toMatch(/primary/)
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('clear calls setOverride(agent, null) — chat "clear" → null arg', async () => {
|
|
492
|
+
const client = mockClient()
|
|
493
|
+
const reply = await handleAuthCommand(
|
|
494
|
+
{ kind: 'override-clear', agent: 'researcher' },
|
|
495
|
+
{ ...admin, client },
|
|
496
|
+
)
|
|
497
|
+
expect(client.setOverride).toHaveBeenCalledTimes(1)
|
|
498
|
+
expect(client.setOverride).toHaveBeenCalledWith('researcher', null)
|
|
499
|
+
expect(reply.text).toMatch(/Override cleared/i)
|
|
500
|
+
expect(reply.text).toMatch(/researcher/)
|
|
501
|
+
})
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
/* ── 7. help text contents ────────────────────────────────────────────── */
|
|
505
|
+
|
|
506
|
+
describe('handleAuthCommand — help text lists every verb', () => {
|
|
507
|
+
it('help reply mentions all the load-bearing verbs', async () => {
|
|
508
|
+
const client = mockClient()
|
|
509
|
+
const reply = await handleAuthCommand(
|
|
510
|
+
{ kind: 'help' },
|
|
511
|
+
{ agentName: 'x', isAdmin: true, client },
|
|
512
|
+
)
|
|
513
|
+
const text = reply.text
|
|
514
|
+
// Verbs (all variants). The help is HTML; <code> wraps each verb.
|
|
515
|
+
for (const fragment of [
|
|
516
|
+
'/auth show',
|
|
517
|
+
'/auth show <agent>',
|
|
518
|
+
'/auth list',
|
|
519
|
+
'/auth use',
|
|
520
|
+
'/auth rotate',
|
|
521
|
+
'/auth add',
|
|
522
|
+
'/auth cancel',
|
|
523
|
+
'/auth rm',
|
|
524
|
+
'/auth refresh',
|
|
525
|
+
'/auth agent override',
|
|
526
|
+
'/auth help',
|
|
527
|
+
]) {
|
|
528
|
+
expect(text).toContain(fragment)
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
})
|
|
@@ -292,13 +292,16 @@ describe('probeQuota — #1163: /v1/messages headers path', () => {
|
|
|
292
292
|
expect(result.detail).toContain('18% / 7d')
|
|
293
293
|
})
|
|
294
294
|
|
|
295
|
-
it('surfaces auth rejection with
|
|
295
|
+
it('surfaces auth rejection with the RFC-H replace-account hint on 403', async () => {
|
|
296
296
|
const fakeFetch: typeof fetch = async () =>
|
|
297
297
|
new Response(null, { status: 403 }) as Response
|
|
298
298
|
|
|
299
299
|
const result = await probeQuota(claudeDir, agentDir, fakeFetch)
|
|
300
300
|
expect(result.status).toBe('degraded')
|
|
301
|
-
|
|
301
|
+
// Post-RFC-H: per-agent `auth login` is retired. probeQuota emits the
|
|
302
|
+
// broker-aware "replace the account" hint pointing at `auth add ...
|
|
303
|
+
// --replace` instead. See telegram-plugin/gateway/boot-probes.ts.
|
|
304
|
+
expect(result.nextStep).toMatch(/switchroom auth add .*--from-oauth --replace/)
|
|
302
305
|
})
|
|
303
306
|
|
|
304
307
|
it('writing rate-limited result to cache produces a readable 30 s entry', () => {
|
|
@@ -1149,14 +1152,18 @@ describe('nextStep — agent systemd states', () => {
|
|
|
1149
1152
|
})
|
|
1150
1153
|
|
|
1151
1154
|
describe('nextStep — quota / hindsight / broker / kernel / scheduler', () => {
|
|
1152
|
-
it('quota: no OAuth token → degraded with
|
|
1155
|
+
it('quota: no OAuth token → degraded with RFC-H add+use hint', async () => {
|
|
1153
1156
|
const dir = mkdtempSync(join(tmpdir(), 'quota-nextstep-'))
|
|
1154
1157
|
const oldCachePath = process.env.SWITCHROOM_QUOTA_CACHE_PATH
|
|
1155
1158
|
process.env.SWITCHROOM_QUOTA_CACHE_PATH = join(dir, 'cache.json')
|
|
1156
1159
|
try {
|
|
1157
1160
|
const r = await probeQuota(dir, dir, (async () => new Response('{}')) as unknown as typeof fetch)
|
|
1158
1161
|
expect(r.status).toBe('degraded')
|
|
1159
|
-
|
|
1162
|
+
// Post-RFC-H: the no-token nextStep points at `auth add` (register a
|
|
1163
|
+
// fleet account) + `auth use` (set fleet active), not the retired
|
|
1164
|
+
// per-agent `auth login`. See telegram-plugin/gateway/boot-probes.ts.
|
|
1165
|
+
expect(r.nextStep).toMatch(/switchroom auth add .*--from-oauth/)
|
|
1166
|
+
expect(r.nextStep).toMatch(/switchroom auth use/)
|
|
1160
1167
|
} finally {
|
|
1161
1168
|
if (oldCachePath) process.env.SWITCHROOM_QUOTA_CACHE_PATH = oldCachePath
|
|
1162
1169
|
else delete process.env.SWITCHROOM_QUOTA_CACHE_PATH
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hostd-dispatch.ts` — the gateway's helper that routes
|
|
3
|
+
* self-restart slash-commands through the hostd UDS when enabled.
|
|
4
|
+
*
|
|
5
|
+
* The config-loading branches are validated by mocking
|
|
6
|
+
* `loadSwitchroomConfig` (the schema's complexity isn't this test's
|
|
7
|
+
* concern — we just need to feed it a known value). The wire-error
|
|
8
|
+
* branch is validated by pointing the helper at a nonexistent socket.
|
|
9
|
+
*
|
|
10
|
+
* The "actually hits a real hostd" path is covered in
|
|
11
|
+
* `tests/host-control/server.test.ts` end-to-end — we don't re-test
|
|
12
|
+
* the server here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
describe,
|
|
17
|
+
it,
|
|
18
|
+
expect,
|
|
19
|
+
beforeEach,
|
|
20
|
+
afterEach,
|
|
21
|
+
vi,
|
|
22
|
+
} from "vitest";
|
|
23
|
+
|
|
24
|
+
const loadConfigMock = vi.fn();
|
|
25
|
+
vi.mock("../../src/config/loader.js", () => ({
|
|
26
|
+
loadConfig: loadConfigMock,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// Import AFTER the mock so the module captures the mocked function.
|
|
30
|
+
const {
|
|
31
|
+
tryHostdDispatch,
|
|
32
|
+
hostdWillBeUsed,
|
|
33
|
+
isHostdEnabled,
|
|
34
|
+
hostdSocketPath,
|
|
35
|
+
_resetHostdEnabledCache,
|
|
36
|
+
} = await import("../gateway/hostd-dispatch.js");
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
_resetHostdEnabledCache();
|
|
40
|
+
loadConfigMock.mockReset();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
_resetHostdEnabledCache();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("isHostdEnabled() — config gate", () => {
|
|
48
|
+
it("returns false when host_control absent", () => {
|
|
49
|
+
loadConfigMock.mockReturnValue({});
|
|
50
|
+
expect(isHostdEnabled()).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns false when host_control.enabled is false", () => {
|
|
54
|
+
loadConfigMock.mockReturnValue({ host_control: { enabled: false } });
|
|
55
|
+
expect(isHostdEnabled()).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns true when host_control.enabled is true", () => {
|
|
59
|
+
loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
|
|
60
|
+
expect(isHostdEnabled()).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns false on config-load throw (best-effort fallback)", () => {
|
|
64
|
+
// Gateway runs in environments where the config may not be
|
|
65
|
+
// readable yet (very-early-boot, broken symlink). The helper must
|
|
66
|
+
// not propagate — it just disables the hostd path.
|
|
67
|
+
loadConfigMock.mockImplementation(() => {
|
|
68
|
+
throw new Error("config: file not found");
|
|
69
|
+
});
|
|
70
|
+
expect(isHostdEnabled()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("caches the result across calls (no re-read)", () => {
|
|
74
|
+
loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
|
|
75
|
+
expect(isHostdEnabled()).toBe(true);
|
|
76
|
+
expect(isHostdEnabled()).toBe(true);
|
|
77
|
+
expect(isHostdEnabled()).toBe(true);
|
|
78
|
+
expect(loadConfigMock).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("hostdWillBeUsed() — config + socket existence", () => {
|
|
83
|
+
it("false when hostd disabled even if socket would be present", () => {
|
|
84
|
+
loadConfigMock.mockReturnValue({});
|
|
85
|
+
expect(hostdWillBeUsed("klanker")).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("false when hostd enabled but per-agent socket isn't bound", () => {
|
|
89
|
+
loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
|
|
90
|
+
// hostdSocketPath() is hard-coded to /run/switchroom/hostd/<name>/sock
|
|
91
|
+
// — that path doesn't exist in the test env, so existsSync returns
|
|
92
|
+
// false and hostdWillBeUsed is false.
|
|
93
|
+
expect(hostdWillBeUsed("klanker-no-such-agent")).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("tryHostdDispatch()", () => {
|
|
98
|
+
it("returns 'not-configured' when hostd disabled", async () => {
|
|
99
|
+
loadConfigMock.mockReturnValue({});
|
|
100
|
+
const result = await tryHostdDispatch("klanker", {
|
|
101
|
+
v: 1,
|
|
102
|
+
op: "agent_restart",
|
|
103
|
+
request_id: "test-1",
|
|
104
|
+
args: { name: "klanker", force: true },
|
|
105
|
+
});
|
|
106
|
+
expect(result).toBe("not-configured");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns 'not-configured' when socket absent", async () => {
|
|
110
|
+
loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
|
|
111
|
+
const result = await tryHostdDispatch("nonexistent-agent", {
|
|
112
|
+
v: 1,
|
|
113
|
+
op: "agent_restart",
|
|
114
|
+
request_id: "test-2",
|
|
115
|
+
args: { name: "nonexistent-agent", force: true },
|
|
116
|
+
});
|
|
117
|
+
expect(result).toBe("not-configured");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("locks the socket-path contract", () => {
|
|
121
|
+
// RFC C pins this path. If the gateway and the compose generator
|
|
122
|
+
// drift apart on the bind path, the mount silently goes nowhere
|
|
123
|
+
// and every dispatch returns "not-configured". Catch any rename
|
|
124
|
+
// in lockstep with the compose-generator test.
|
|
125
|
+
expect(hostdSocketPath("klanker")).toBe(
|
|
126
|
+
"/run/switchroom/hostd/klanker/sock",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|