switchroom 0.14.27 → 0.14.29

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.
Files changed (28) hide show
  1. package/dist/cli/switchroom.js +20 -4
  2. package/dist/host-control/main.js +2 -2
  3. package/package.json +1 -1
  4. package/telegram-plugin/bridge/bridge.ts +15 -0
  5. package/telegram-plugin/card-format.ts +7 -4
  6. package/telegram-plugin/dist/bridge/bridge.js +18 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +2151 -1729
  8. package/telegram-plugin/dist/server.js +18 -0
  9. package/telegram-plugin/gateway/gateway.ts +464 -12
  10. package/telegram-plugin/history.ts +16 -4
  11. package/telegram-plugin/permission-title.ts +48 -0
  12. package/telegram-plugin/registry/subagents-schema.ts +35 -0
  13. package/telegram-plugin/registry/subagents.test.ts +78 -0
  14. package/telegram-plugin/secret-detect/patterns.ts +8 -0
  15. package/telegram-plugin/secret-detect/redact.ts +76 -0
  16. package/telegram-plugin/session-tail.ts +15 -0
  17. package/telegram-plugin/subagent-watcher.ts +19 -1
  18. package/telegram-plugin/tests/card-format.test.ts +16 -0
  19. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +80 -0
  20. package/telegram-plugin/tests/gateway-request-secret.test.ts +78 -0
  21. package/telegram-plugin/tests/history.test.ts +59 -0
  22. package/telegram-plugin/tests/permission-title.test.ts +68 -0
  23. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +35 -0
  24. package/telegram-plugin/tests/secret-detect-sanctum.test.ts +115 -0
  25. package/telegram-plugin/tests/session-tail.test.ts +43 -0
  26. package/telegram-plugin/tests/worker-activity-feed.test.ts +15 -0
  27. package/telegram-plugin/uat/scenarios/jtbd-request-secret-dm.test.ts +101 -0
  28. package/telegram-plugin/worker-activity-feed.ts +5 -2
@@ -12,6 +12,7 @@ import {
12
12
  naturalAction,
13
13
  describeGrant,
14
14
  formatPermissionCardBody,
15
+ formatPermissionResumeMessage,
15
16
  } from '../permission-title.js'
16
17
  import type { ScopeOption } from '../permission-rule.js'
17
18
 
@@ -193,3 +194,70 @@ describe('describeGrant — phrased from the chosen scope', () => {
193
194
  )
194
195
  })
195
196
  })
197
+
198
+ describe('formatPermissionResumeMessage — agent-voiced verdict ack', () => {
199
+ test('allow names the work it is resuming', () => {
200
+ expect(
201
+ formatPermissionResumeMessage({
202
+ agentName: 'gymbro',
203
+ behavior: 'allow',
204
+ action: 'edit: supplement-log.md',
205
+ }),
206
+ ).toBe('▶️ <b>Gymbro</b> — got it, continuing: <i>edit: supplement-log.md</i>')
207
+ })
208
+
209
+ test('deny names what it will skip, lower-cased inline', () => {
210
+ expect(
211
+ formatPermissionResumeMessage({
212
+ agentName: 'ziggy',
213
+ behavior: 'deny',
214
+ action: 'Search the web',
215
+ }),
216
+ ).toBe("🚫 <b>Ziggy</b> — noted, I won't search the web. Continuing without it.")
217
+ })
218
+
219
+ test('TTL auto-deny variant reads as a timeout, not a tap', () => {
220
+ expect(
221
+ formatPermissionResumeMessage({
222
+ agentName: 'finn',
223
+ behavior: 'deny',
224
+ action: 'run: deploy.sh',
225
+ timeoutMinutes: 5,
226
+ }),
227
+ ).toBe('🚫 <b>Finn</b> — no answer in 5m, continuing without it (<i>run: deploy.sh</i>).')
228
+ })
229
+
230
+ test('HTML-escapes a hostile action phrase (no raw </>& injection)', () => {
231
+ const out = formatPermissionResumeMessage({
232
+ agentName: 'clerk',
233
+ behavior: 'allow',
234
+ action: 'run: echo <b>&"pwned"</b>',
235
+ })
236
+ expect(out).toContain('&lt;b&gt;&amp;')
237
+ expect(out).not.toContain('<b>&"pwned"')
238
+ })
239
+
240
+ test('cap-first on the agent name', () => {
241
+ const out = formatPermissionResumeMessage({
242
+ agentName: 'lawgpt',
243
+ behavior: 'allow',
244
+ action: 'read: contract.pdf',
245
+ })
246
+ expect(out).toContain('<b>Lawgpt</b>')
247
+ })
248
+
249
+ test('empty action falls back to a generic continue (no dangling phrase)', () => {
250
+ expect(
251
+ formatPermissionResumeMessage({ agentName: 'carrie', behavior: 'allow', action: '' }),
252
+ ).toBe('▶️ <b>Carrie</b> — got it, back to work.')
253
+ expect(
254
+ formatPermissionResumeMessage({ agentName: 'carrie', behavior: 'deny', action: ' ' }),
255
+ ).toBe('🚫 <b>Carrie</b> — noted, continuing without it.')
256
+ })
257
+
258
+ test('null agent name degrades to a neutral "Agent" label', () => {
259
+ expect(
260
+ formatPermissionResumeMessage({ agentName: null, behavior: 'allow', action: 'edit: x.md' }),
261
+ ).toBe('▶️ <b>Agent</b> — got it, continuing: <i>edit: x.md</i>')
262
+ })
263
+ })
@@ -22,6 +22,12 @@
22
22
  * This guard fails loudly if any `dispatchPermissionVerdict(...)`
23
23
  * callsite is not paired with a `resumeReactionAfterVerdict()` within a
24
24
  * few lines — i.e. a new (or refactored) verdict path drops the resume.
25
+ *
26
+ * Same pin applies to `postPermissionResumeMessage(...)`: the distinct
27
+ * agent-voiced "got it, continuing: <work>" message is the legible signal
28
+ * the operator actually sees (the reaction lands on a far-up message; the
29
+ * card edit is a one-liner). It rides the exact same 5-paths-drift hazard,
30
+ * so every verdict path must post it too.
25
31
  */
26
32
 
27
33
  import { describe, it, expect } from 'vitest'
@@ -83,4 +89,33 @@ describe('permission verdict → resume reaction wiring', () => {
83
89
  true,
84
90
  )
85
91
  })
92
+
93
+ // postPermissionResumeMessage rides ~1–2 lines after resumeReactionAfterVerdict
94
+ // on every path, so it can sit a touch further from the dispatch than the
95
+ // resume call — give it a little more headroom.
96
+ const POST_WINDOW = 20
97
+
98
+ it('every dispatchPermissionVerdict() callsite posts the agent-voiced resume message via postPermissionResumeMessage()', () => {
99
+ const unpaired: number[] = []
100
+ for (const idx of dispatchCallsites) {
101
+ const window = LINES.slice(idx, idx + POST_WINDOW + 1).join('\n')
102
+ if (!/\bpostPermissionResumeMessage\s*\(/.test(window)) {
103
+ unpaired.push(idx + 1)
104
+ }
105
+ }
106
+ expect(
107
+ unpaired,
108
+ `dispatchPermissionVerdict() at gateway.ts line(s) ` +
109
+ `${unpaired.join(', ')} has no postPermissionResumeMessage() within ` +
110
+ `${POST_WINDOW} lines — that verdict path resumes the turn silently, ` +
111
+ `so the operator never gets the "got it, continuing: <work>" message. ` +
112
+ `Add the post call next to resumeReactionAfterVerdict() (see sibling paths).`,
113
+ ).toEqual([])
114
+ })
115
+
116
+ it('the resume-message helper still exists', () => {
117
+ expect(
118
+ /function\s+postPermissionResumeMessage\s*\(/.test(GATEWAY_SRC),
119
+ ).toBe(true)
120
+ })
86
121
  })
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { detectSecrets } from '../secret-detect/index.js'
3
+ import { redact } from '../secret-detect/redact.js'
4
+
5
+ /**
6
+ * Regression for the 2026-06-01 incident: a live Laravel Sanctum / Coolify
7
+ * personal-access token (`<id>|<40-char base62>`, e.g. `17|aB3d…`) pasted by
8
+ * a USER into chat slipped every existing pattern and persisted in plaintext.
9
+ *
10
+ * Diagnosis: the inbound gate (gateway handleInbound) already runs
11
+ * `detectSecrets` at ingest, fail-closed, BEFORE recordInbound + IPC dispatch
12
+ * — so the leak was NOT an inbound bypass and NOT render-time masking. It was
13
+ * pure PATTERN COVERAGE: no rule matched the `<id>|<token>` shape. These tests
14
+ * pin the new `laravel_sanctum_token` rule and the redactor that backs both
15
+ * the history-store persistence (both directions) and the issues pipeline.
16
+ */
17
+
18
+ // Build token fixtures by concatenation so the source file never contains a
19
+ // contiguous secret-shaped literal (repo Push Protection / no-pii lint).
20
+ const ID = '17'
21
+ const BODY40 = 'aB3dE6fH9j'.repeat(4) // 40 base62 chars — Sanctum's Str::random(40)
22
+ const SANCTUM = `${ID}|${BODY40}` // e.g. 17|aB3dE6fH9j…
23
+
24
+ describe('laravel/coolify sanctum token detection', () => {
25
+ it('detects <id>|<40-char> as a high-confidence laravel_sanctum_token', () => {
26
+ const hits = detectSecrets(`here is the token: ${SANCTUM}`)
27
+ const hit = hits.find((d) => d.rule_id === 'laravel_sanctum_token')
28
+ expect(hit).toBeDefined()
29
+ expect(hit!.matched_text).toBe(SANCTUM)
30
+ expect(hit!.confidence).toBe('high')
31
+ expect(hit!.suppressed).toBe(false)
32
+ })
33
+
34
+ it('satisfies the exact inbound-gate predicate (high + not suppressed)', () => {
35
+ // This is verbatim the condition gateway handleInbound uses to decide to
36
+ // delete + defer-to-vault: if it is truthy, the pasted token is
37
+ // intercepted before recordInbound() and the IPC broadcast to the agent.
38
+ const detections = detectSecrets(SANCTUM)
39
+ const gateHit = detections.find((d) => d.confidence === 'high' && !d.suppressed)
40
+ expect(gateHit).toBeDefined()
41
+ })
42
+
43
+ it('redact() masks the token in place, preserving surrounding prose', () => {
44
+ const out = redact(`new token: ${SANCTUM}\nuse it for the deploy API`)
45
+ expect(out).not.toContain(SANCTUM)
46
+ expect(out).not.toContain(BODY40)
47
+ expect(out).toContain('[REDACTED:laravel_sanctum_token]')
48
+ // Surrounding prose is untouched.
49
+ expect(out).toContain('new token:')
50
+ expect(out).toContain('use it for the deploy API')
51
+ })
52
+
53
+ it('covers longer-than-40 token bodies', () => {
54
+ const longTok = `42|${'Xy7Zk2Qp9w'.repeat(6)}` // 60 base62 chars
55
+ const hits = detectSecrets(longTok)
56
+ expect(hits.find((d) => d.rule_id === 'laravel_sanctum_token')?.matched_text).toBe(longTok)
57
+ })
58
+
59
+ it('catches a token mid-sentence and masks only the token', () => {
60
+ const out = redact(`use ${SANCTUM} when deploying, ok?`)
61
+ expect(out).not.toContain(SANCTUM)
62
+ expect(out).toContain('use ')
63
+ expect(out).toContain(' when deploying, ok?')
64
+ })
65
+
66
+ it('catches multiple tokens in one message (redact right-to-left loop)', () => {
67
+ const second = `88|${'mK2pR7vT4x'.repeat(4)}` // distinct <id>|<40 base62>
68
+ const detected = detectSecrets(`old ${SANCTUM} new ${second}`)
69
+ .filter((d) => d.rule_id === 'laravel_sanctum_token')
70
+ expect(detected).toHaveLength(2)
71
+ const out = redact(`old ${SANCTUM} new ${second}`)
72
+ expect(out).not.toContain(SANCTUM)
73
+ expect(out).not.toContain(second)
74
+ expect(out).not.toContain(BODY40)
75
+ })
76
+
77
+ describe('false-positive guards', () => {
78
+ it('does NOT match a pipe-joined value under the 40-char floor', () => {
79
+ const short = `1|${'abcDEF123'}` // 9 chars after the pipe
80
+ expect(detectSecrets(short).some((d) => d.rule_id === 'laravel_sanctum_token')).toBe(false)
81
+ })
82
+
83
+ it('does NOT match exactly 39 base62 chars (one under the floor)', () => {
84
+ const justUnder = `17|${BODY40.slice(0, 39)}`
85
+ expect(
86
+ detectSecrets(justUnder).some((d) => d.rule_id === 'laravel_sanctum_token'),
87
+ ).toBe(false)
88
+ })
89
+
90
+ it('does NOT match a number with no pipe-delimited token', () => {
91
+ expect(
92
+ detectSecrets('order 17 shipped 40 items').some(
93
+ (d) => d.rule_id === 'laravel_sanctum_token',
94
+ ),
95
+ ).toBe(false)
96
+ })
97
+
98
+ it('does NOT match a spaced markdown table cell', () => {
99
+ // Table cells are `| value |` with spaces around the pipe; the Sanctum
100
+ // shape has the token immediately adjacent to the pipe.
101
+ const table = `| 17 | ${BODY40} |`
102
+ expect(
103
+ detectSecrets(table).some((d) => d.rule_id === 'laravel_sanctum_token'),
104
+ ).toBe(false)
105
+ })
106
+ })
107
+
108
+ it('suppresses a token sitting next to an example/fixture marker', () => {
109
+ // A documented example must not trigger a destructive delete+vault.
110
+ const hits = detectSecrets(`example token: ${SANCTUM}`)
111
+ const hit = hits.find((d) => d.rule_id === 'laravel_sanctum_token')
112
+ expect(hit).toBeDefined()
113
+ expect(hit!.suppressed).toBe(true)
114
+ })
115
+ })
@@ -492,6 +492,49 @@ describe('projectSubagentLine', () => {
492
492
  expect(events).toEqual([{ kind: 'sub_agent_turn_end', agentId: 'X' }])
493
493
  })
494
494
 
495
+ it('emits sub_agent_turn_end after the text when the final assistant message stop_reason is end_turn', () => {
496
+ // Background `Agent` workers (claude ≥2.1.156) never write the
497
+ // system/turn_duration line, only a final assistant message with
498
+ // stop_reason 'end_turn'. That IS the authoritative completion signal —
499
+ // without treating it as terminal the card hung "running" until the
500
+ // ~5-min stall-synthesis net fired (the screenshot bug).
501
+ const st = { hasEmittedStart: true }
502
+ const events = projectSubagentLine(
503
+ JSON.stringify({
504
+ type: 'assistant',
505
+ message: {
506
+ stop_reason: 'end_turn',
507
+ content: [{ type: 'text', text: 'Done. Fixed the bug.' }],
508
+ },
509
+ }),
510
+ 'X',
511
+ st,
512
+ )
513
+ // Text first (so the final summary still renders), turn_end last.
514
+ expect(events).toEqual([
515
+ { kind: 'sub_agent_text', agentId: 'X', text: 'Done. Fixed the bug.' },
516
+ { kind: 'sub_agent_turn_end', agentId: 'X' },
517
+ ])
518
+ })
519
+
520
+ it('does NOT emit sub_agent_turn_end for a tool-using assistant message (stop_reason tool_use)', () => {
521
+ // A mid-run assistant message that calls a tool has stop_reason 'tool_use'
522
+ // and keeps going — it must not be mistaken for completion.
523
+ const st = { hasEmittedStart: true }
524
+ const events = projectSubagentLine(
525
+ JSON.stringify({
526
+ type: 'assistant',
527
+ message: {
528
+ stop_reason: 'tool_use',
529
+ content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
530
+ },
531
+ }),
532
+ 'X',
533
+ st,
534
+ )
535
+ expect(events.some((e) => e.kind === 'sub_agent_turn_end')).toBe(false)
536
+ })
537
+
495
538
  it('skips malformed lines silently', () => {
496
539
  const st = { hasEmittedStart: false }
497
540
  expect(projectSubagentLine('{not-json', 'X', st)).toEqual([])
@@ -187,6 +187,21 @@ describe('renderWorkerActivity', () => {
187
187
  expect(done).not.toMatch(/(^|\n)\s*-{3,}\s*(\n|$)/)
188
188
  })
189
189
 
190
+ it('renders a multi-line narrative entry as one clean step (no raw ## leak)', () => {
191
+ // Screenshot regression: a running-card step whose narrative entry is
192
+ // itself multi-line ("Done.\n\n## Summary\n…"). The heading marker must
193
+ // not leak and the step must collapse to a single visual line.
194
+ const out = renderWorkerActivity(
195
+ view({
196
+ narrativeLines: ['Done.\n\n## Summary\n\nFixed the bug where a bad password logged you out'],
197
+ latestSummary: '',
198
+ }),
199
+ )
200
+ expect(out).not.toContain('## Summary')
201
+ expect(out).not.toContain('\n\n')
202
+ expect(out).toContain('Done. Summary Fixed the bug where a bad password logged you out')
203
+ })
204
+
190
205
  it('escapes HTML inside narrative lines', () => {
191
206
  const out = renderWorkerActivity(view({ narrativeLines: ['a <b>x</b> & y'] }))
192
207
  expect(out).toContain('a &lt;b&gt;x&lt;/b&gt; &amp; y')
@@ -0,0 +1,101 @@
1
+ /**
2
+ * End-to-end UAT for the agent-initiated `request_secret` flow (#2045).
3
+ *
4
+ * The upstream fix behind the 2026-06-01 incident: an agent must never ask
5
+ * the user to paste a secret into chat. Instead it calls `request_secret`,
6
+ * the operator taps [Provide securely] and sends the value once, and the
7
+ * gateway deletes the message + writes it straight to the vault — the raw
8
+ * value never lands in history/logs and is never returned to the agent.
9
+ *
10
+ * This round-trips through real Telegram + real broker + real agent and
11
+ * asserts the FINAL state: (a) the secure card renders with the right
12
+ * button, (b) after the operator provides the value the bot confirms a
13
+ * vault save, (c) the value actually landed in the vault (host-side read),
14
+ * and (d) the raw value never reappears in a bot message.
15
+ *
16
+ * **Skipped by default.** To unskip:
17
+ *
18
+ * 1. Standard UAT preflight (`uat/SETUP.md` §5-6) — test-harness agent live
19
+ * (running build WITH request_secret, #2045), driver session auth'd,
20
+ * env vars set.
21
+ * 2. `SWITCHROOM_VAULT_PASSPHRASE` in env (read-back asserts the value
22
+ * landed; under telegram-id approval mode the save itself is
23
+ * posture-attested and needs no passphrase).
24
+ * 3. Remove `describe.skip`.
25
+ *
26
+ * Cleanup is operator-side: `switchroom vault rm uat/req-secret-target`.
27
+ */
28
+
29
+ import { execFileSync } from "node:child_process";
30
+ import { describe, expect, it } from "vitest";
31
+ import { spinUp } from "../harness.js";
32
+
33
+ const TARGET_KEY = "uat/req-secret-target";
34
+ // Built at runtime so the source never holds a contiguous secret literal.
35
+ const PROVIDED_VALUE = "sentinel-2045-" + "Ab3xK9pQ".repeat(2);
36
+
37
+ function hostVaultGet(key: string): string {
38
+ try {
39
+ return execFileSync("switchroom", ["vault", "get", key], {
40
+ encoding: "utf-8",
41
+ env: { ...process.env },
42
+ }).trim();
43
+ } catch {
44
+ return "";
45
+ }
46
+ }
47
+
48
+ describe.skip("uat: request_secret end-to-end (#2045)", () => {
49
+ it(
50
+ "agent calls request_secret → operator provides → value vaulted, never echoed",
51
+ async () => {
52
+ const sc = await spinUp({ agent: "test-harness" });
53
+ try {
54
+ // 1. Prompt the agent to call request_secret for a key it lacks.
55
+ // (We don't fire the card from the driver — the point is to
56
+ // cover the agent → gateway → capture → vault path.)
57
+ await sc.sendDM(
58
+ `I need an API token but it is NOT in your vault. Use your ` +
59
+ `request_secret MCP tool with key="${TARGET_KEY}", ` +
60
+ `reason="UAT for #2045" to ask me for it securely. Do NOT ask me ` +
61
+ `to paste it as a normal message.`,
62
+ );
63
+
64
+ // 2. The secure card (request_secret renders "needs a secret").
65
+ const card = await sc.expectMessage(/needs a secret/i, {
66
+ from: "bot",
67
+ timeout: 60_000,
68
+ });
69
+
70
+ // 3. It must carry a [Provide securely] button.
71
+ const kb = await sc.driver.getKeyboard(sc.botUserId, card.messageId);
72
+ expect(kb).not.toBeNull();
73
+ const provide = kb!
74
+ .flat()
75
+ .find((b) => b.callbackData !== undefined && /provide/i.test(b.text));
76
+ expect(provide, "card should have a [Provide securely] button").toBeDefined();
77
+
78
+ // 4. Tap Provide → the card arms capture + prompts for the value.
79
+ await sc.driver.pressButton(sc.botUserId, card.messageId, provide!.callbackData!);
80
+ await sc.expectMessage(/Send the value for/i, { from: "bot", timeout: 15_000 });
81
+
82
+ // 5. Send the value. The gateway deletes it + writes to the vault.
83
+ await sc.sendDM(PROVIDED_VALUE);
84
+ const confirm = await sc.expectMessage(/saved as/i, {
85
+ from: "bot",
86
+ timeout: 30_000,
87
+ });
88
+
89
+ // 6. The confirmation references the key, NOT the raw value.
90
+ expect(confirm.text).toContain(`vault:${TARGET_KEY}`);
91
+ expect(confirm.text).not.toContain(PROVIDED_VALUE);
92
+
93
+ // 7. Load-bearing: the value actually landed in the vault.
94
+ expect(hostVaultGet(TARGET_KEY)).toBe(PROVIDED_VALUE);
95
+ } finally {
96
+ await sc.tearDown();
97
+ }
98
+ },
99
+ 300_000,
100
+ );
101
+ });
@@ -147,7 +147,10 @@ export function renderWorkerActivity(v: WorkerActivityView): string {
147
147
  const finished = v.state === 'done' || v.state === 'failed'
148
148
 
149
149
  const steps = (v.narrativeLines ?? [])
150
- .map((s) => stripMarkdown(s))
150
+ // A narrative entry can itself be multi-line (e.g. a worker's final
151
+ // "Done.\n\n## Summary\n…"). Collapse to one visual line so a step
152
+ // slot stays single-line after the per-line markdown strip.
153
+ .map((s) => stripMarkdown(s).replace(/\s+/g, ' ').trim())
151
154
  .filter((s) => s.length > 0)
152
155
  .map((s) => escapeHtml(truncate(s, STEP_MAX)))
153
156
 
@@ -172,7 +175,7 @@ export function renderWorkerActivity(v: WorkerActivityView): string {
172
175
  } else {
173
176
  // Back-compat for direct render callers that pass only latestSummary;
174
177
  // the manager always supplies narrativeLines.
175
- const summary = stripMarkdown(v.latestSummary)
178
+ const summary = stripMarkdown(v.latestSummary).replace(/\s+/g, ' ').trim()
176
179
  if (summary.length > 0) {
177
180
  lines.push(`<b>→ ${escapeHtml(truncate(summary, STEP_MAX))}</b>`)
178
181
  } else {