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.
- package/dist/cli/switchroom.js +20 -4
- package/dist/host-control/main.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +15 -0
- package/telegram-plugin/card-format.ts +7 -4
- package/telegram-plugin/dist/bridge/bridge.js +18 -0
- package/telegram-plugin/dist/gateway/gateway.js +2151 -1729
- package/telegram-plugin/dist/server.js +18 -0
- package/telegram-plugin/gateway/gateway.ts +464 -12
- package/telegram-plugin/history.ts +16 -4
- package/telegram-plugin/permission-title.ts +48 -0
- package/telegram-plugin/registry/subagents-schema.ts +35 -0
- package/telegram-plugin/registry/subagents.test.ts +78 -0
- package/telegram-plugin/secret-detect/patterns.ts +8 -0
- package/telegram-plugin/secret-detect/redact.ts +76 -0
- package/telegram-plugin/session-tail.ts +15 -0
- package/telegram-plugin/subagent-watcher.ts +19 -1
- package/telegram-plugin/tests/card-format.test.ts +16 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +80 -0
- package/telegram-plugin/tests/gateway-request-secret.test.ts +78 -0
- package/telegram-plugin/tests/history.test.ts +59 -0
- package/telegram-plugin/tests/permission-title.test.ts +68 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +35 -0
- package/telegram-plugin/tests/secret-detect-sanctum.test.ts +115 -0
- package/telegram-plugin/tests/session-tail.test.ts +43 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +15 -0
- package/telegram-plugin/uat/scenarios/jtbd-request-secret-dm.test.ts +101 -0
- 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('<b>&')
|
|
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 <b>x</b> & 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
|
-
.
|
|
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 {
|