switchroom 0.14.6 โ†’ 0.14.8

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.
@@ -1,32 +1,27 @@
1
1
  /**
2
- * Structural contract tests for the "๐Ÿ” Always allow" handler in
3
- * gateway.ts (the `behavior === 'always'` branch of the perm: callback
4
- * dispatcher).
2
+ * Structural contract tests for the durable "๐Ÿ” Always allow" handler
3
+ * in gateway.ts (the `behavior === 'always'` branch of the perm:
4
+ * callback dispatcher), reworked for #1977.
5
5
  *
6
6
  * Why structural: the handler lives inside a Grammy callback closure
7
7
  * that's not exported. Full-function invocation would require a complete
8
- * Grammy + switchroomExec harness. Instead, we pin the source-level
9
- * invariants that were introduced to fix the silent-failure bug:
8
+ * Grammy + hostd + switchroomExec harness. The behavioural pieces are
9
+ * covered by the pure-function tests (permission-diff.test.ts) and the
10
+ * correlation handler tests (always-allow-correlation.test.ts); this
11
+ * file pins the source-level invariants of the orchestration block.
10
12
  *
11
- * 1. Loud failure text โ€” the failure path must NOT read like success
12
- * (`โœ… Allowed โ€ฆ`). After the fix, both the toast (ackText) and the
13
- * chat edit (editLabel) use the `โš ๏ธ` marker.
14
- * 2. Post-write verification โ€” after `switchroomExec` returns success
15
- * the handler MUST re-read the config and check that the rule is
16
- * actually present in `tools.allow`. If the check fails it sets
17
- * grantOk=false and surfaces the loud message.
18
- * 3. Success path unchanged โ€” when `grantOk` is true the success
19
- * strings (`๐Ÿ” Always allow โ€ฆ`, `restart agent for full effect`)
20
- * are still present.
21
- * 4. Error reason capture โ€” `grantFailReason` is declared and
22
- * populated from `(err as Error).message` so the root cause can
23
- * appear in logs; it is NOT silently swallowed into `message`-less
24
- * stderr output.
13
+ * Post-#1977 contract:
14
+ * 1. The in-flight Allow verdict fires IMMEDIATELY (before awaiting
15
+ * hostd) so the turn never blocks on host-config persistence.
16
+ * 2. Durable persistence goes through hostd's `config_propose_edit`
17
+ * (synthesizeAllowRuleDiff โ†’ tryHostdDispatch).
18
+ * 3. The legacy `switchroom agent grant` path is the
19
+ * not-configured fallback ONLY (not the primary path), and it
20
+ * still verifies via isRulePersisted with honest messaging.
21
+ * 4. Failure text never falsely claims durable success.
25
22
  *
26
23
  * Slicing strategy: we extract the `if (behavior === 'always') {` block
27
- * from gateway.ts and run string assertions against that slice only โ€”
28
- * so additions elsewhere in the 17k-line file don't produce false
29
- * positives or negatives.
24
+ * from gateway.ts and run string assertions against that slice only.
30
25
  */
31
26
 
32
27
  import { describe, it, expect } from 'vitest'
@@ -53,95 +48,96 @@ function sliceAlwaysBlock(): string {
53
48
 
54
49
  const alwaysBlock = sliceAlwaysBlock()
55
50
 
56
- describe('always-allow handler โ€” loud failure invariants', () => {
57
- it('failure ackText uses the โš ๏ธ warning marker, not โœ…', () => {
58
- // The failure path must be unambiguous. Before the fix, the failure
59
- // ackText started with "โœ… Allowed โ€ฆ" which reads like success.
60
- expect(alwaysBlock).toContain(
61
- `โš ๏ธ Allowed for now, but "always" did NOT save โ€” it will ask again after restart. Check gateway log.`,
62
- )
63
- // Confirm the old misleading text is gone.
64
- expect(alwaysBlock).not.toContain('โœ… Allowed (always-allow yaml edit failed')
51
+ describe('always-allow handler โ€” immediate verdict dispatch (turn must not block)', () => {
52
+ it('dispatches the permission verdict BEFORE the hostd await', () => {
53
+ const verdictIdx = alwaysBlock.indexOf('dispatchPermissionVerdict({')
54
+ const hostdAwaitIdx = alwaysBlock.indexOf('await tryHostdDispatch(')
55
+ expect(verdictIdx).toBeGreaterThan(-1)
56
+ expect(hostdAwaitIdx).toBeGreaterThan(-1)
57
+ // The verdict fires first โ€” independent of the durable persistence
58
+ // round-trip.
59
+ expect(verdictIdx).toBeLessThan(hostdAwaitIdx)
60
+ })
61
+
62
+ it('carries the resolved rule on the verdict so the bridge caches it', () => {
63
+ expect(alwaysBlock).toContain("behavior: 'allow'")
64
+ expect(alwaysBlock).toContain('rule: rule.rule')
65
65
  })
66
66
 
67
- it('failure editLabel uses the โš ๏ธ warning marker, not โœ…', () => {
68
- // The inline-keyboard collapse edit also must NOT look like success.
69
- expect(alwaysBlock).toContain(
70
- `โš ๏ธ <b>Allowed for now โ€” "always" did NOT save.</b> It will ask again after restart. Check gateway log.`,
71
- )
72
- // Confirm the old misleading text is gone.
73
- expect(alwaysBlock).not.toContain('โœ… <b>Allowed</b> (always-allow rule edit failed')
67
+ it('does NOT pass a synthInbound to finalizeCallback (verdict already fired)', () => {
68
+ // The verdict is dispatched directly above; finalizeCallback only
69
+ // edits the card. A synthInbound here would double-fire.
70
+ const finalizeIdx = alwaysBlock.indexOf('await finalizeCallback(ctx, {')
71
+ const after = alwaysBlock.slice(finalizeIdx)
72
+ expect(finalizeIdx).toBeGreaterThan(-1)
73
+ expect(after).not.toContain('synthInbound')
74
74
  })
75
75
  })
76
76
 
77
- describe('always-allow handler โ€” success path unchanged', () => {
78
- it('success ackText still uses ๐Ÿ” and names the rule', () => {
79
- expect(alwaysBlock).toContain('`๐Ÿ” Always allow ${rule.label} for ${agentName}`')
77
+ describe('always-allow handler โ€” durable hostd persistence', () => {
78
+ it('synthesizes a unified diff from the raw config text', () => {
79
+ expect(alwaysBlock).toContain('synthesizeAllowRuleDiff({')
80
+ expect(alwaysBlock).toContain("op: 'config_propose_edit'")
80
81
  })
81
82
 
82
- it('success editLabel still uses ๐Ÿ” bold + restart hint', () => {
83
- expect(alwaysBlock).toContain('restart agent for full effect')
84
- expect(alwaysBlock).toContain('๐Ÿ” <b>Always allow')
83
+ it('reads the RAW config bytes (readFileSync), not the parsed config', () => {
84
+ // The diff context lines must byte-match the on-disk file, so we
85
+ // read literal bytes rather than re-serialising loadSwitchroomConfig.
86
+ expect(alwaysBlock).toContain('readFileSync(')
85
87
  })
86
- })
87
88
 
88
- describe('always-allow handler โ€” post-write verification', () => {
89
- it('reloads config after switchroomExec returns', () => {
90
- // The verification block must call loadSwitchroomConfig() AFTER
91
- // the switchroomExec call to confirm the rule landed in the
92
- // resolved tools.allow.
93
- const execIdx = alwaysBlock.indexOf("switchroomExec(['agent', 'grant'")
94
- const loadIdx = alwaysBlock.indexOf('loadSwitchroomConfig()', execIdx)
95
- expect(execIdx).toBeGreaterThan(-1)
96
- expect(loadIdx).toBeGreaterThan(execIdx)
89
+ it('passes a long timeout to tryHostdDispatch (apply+reconcile blocks)', () => {
90
+ expect(alwaysBlock).toContain('await tryHostdDispatch(agentName, req, 60_000)')
97
91
  })
98
92
 
99
- it('calls resolveAgentConfig to obtain the merged tools.allow list', () => {
100
- const execIdx = alwaysBlock.indexOf("switchroomExec(['agent', 'grant'")
101
- const resolveIdx = alwaysBlock.indexOf('resolveAgentConfig(', execIdx)
102
- expect(resolveIdx).toBeGreaterThan(execIdx)
93
+ it('registers + cleans up the single-tap correlation entry', () => {
94
+ expect(alwaysBlock).toContain('pendingAlwaysAllowCorrelations.set(correlationKey')
95
+ // Cleanup in a finally so it can never be replayed.
96
+ const finallyIdx = alwaysBlock.indexOf('} finally {')
97
+ const deleteIdx = alwaysBlock.indexOf('pendingAlwaysAllowCorrelations.delete(correlationKey)', finallyIdx)
98
+ expect(finallyIdx).toBeGreaterThan(-1)
99
+ expect(deleteIdx).toBeGreaterThan(finallyIdx)
103
100
  })
104
101
 
105
- it('calls isRulePersisted(allowList, rule.rule) after the reload', () => {
106
- // The handler delegates the membership check to the extracted pure
107
- // helper so the behavioral test in always-allow-persist.test.ts can
108
- // cover the same code path.
109
- expect(alwaysBlock).toContain('isRulePersisted(allowList, rule.rule)')
102
+ it('treats E_CONFIG_EDIT_DISABLED specially (no legacy fallback, points at the flag)', () => {
103
+ expect(alwaysBlock).toContain('E_CONFIG_EDIT_DISABLED')
104
+ expect(alwaysBlock).toContain('hostd.config_edit_enabled')
105
+ })
106
+ })
107
+
108
+ describe('always-allow handler โ€” legacy fallback ONLY when hostd not-configured', () => {
109
+ it('falls back to switchroom agent grant only on not-configured', () => {
110
+ const notConfiguredIdx = alwaysBlock.indexOf("resp === 'not-configured'")
111
+ const grantIdx = alwaysBlock.indexOf("switchroomExec(['agent', 'grant'")
112
+ expect(notConfiguredIdx).toBeGreaterThan(-1)
113
+ expect(grantIdx).toBeGreaterThan(-1)
114
+ // The grant shellout lives AFTER the not-configured branch sets
115
+ // legacy=true (i.e. it is the fallback, not the primary path).
116
+ expect(grantIdx).toBeGreaterThan(notConfiguredIdx)
110
117
  })
111
118
 
112
- it('sets grantOk=true only when isRulePersisted returns true', () => {
113
- // grantOk=true must be inside the `if (isRulePersisted(...))` branch,
114
- // not unconditionally after switchroomExec.
115
- const persistIdx = alwaysBlock.indexOf('isRulePersisted(allowList, rule.rule)')
116
- const grantOkIdx = alwaysBlock.indexOf('grantOk = true', persistIdx)
117
- expect(persistIdx).toBeGreaterThan(-1)
118
- expect(grantOkIdx).toBeGreaterThan(persistIdx)
119
- // Confirm grantOk=true does NOT appear before the persistence check
120
- // (i.e., not unconditionally on switchroomExec success as in the old code).
121
- const grantOkFirst = alwaysBlock.indexOf('grantOk = true')
122
- expect(grantOkFirst).toBeGreaterThanOrEqual(persistIdx)
119
+ it('emits the legacy-spawn deprecation warning on the not-configured path', () => {
120
+ expect(alwaysBlock).toContain("warnLegacySpawnIfHostdDisabled('always-allow')")
123
121
  })
124
122
 
125
- it('logs a VERIFY FAILED message when the rule is absent after the write', () => {
126
- expect(alwaysBlock).toContain('always-allow VERIFY FAILED')
123
+ it('verifies the legacy write landed via isRulePersisted', () => {
124
+ expect(alwaysBlock).toContain('isRulePersisted(allowList, rule.rule)')
125
+ expect(alwaysBlock).toContain('resolveAgentConfig(')
127
126
  })
128
127
 
129
- it('surfaces config-location drift as a failure reason', () => {
130
- expect(alwaysBlock).toContain('config location may have drifted')
128
+ it('legacy success messaging is honest about being the legacy path', () => {
129
+ expect(alwaysBlock).toContain('(legacy path)')
131
130
  })
132
131
  })
133
132
 
134
- describe('always-allow handler โ€” error reason capture', () => {
135
- it('declares grantFailReason to capture the root cause', () => {
136
- expect(alwaysBlock).toContain('let grantFailReason')
133
+ describe('always-allow handler โ€” loud failure invariants', () => {
134
+ it('failure text uses the โš ๏ธ warning marker, never a false โœ… success', () => {
135
+ expect(alwaysBlock).toContain('did NOT save')
136
+ expect(alwaysBlock).not.toContain('โœ… Allowed (always-allow yaml edit failed')
137
137
  })
138
138
 
139
- it('populates grantFailReason from the thrown error on switchroomExec failure', () => {
140
- // After the catch for switchroomExec, grantFailReason must be set
141
- // from the error object so log messages can show the actual cause.
142
- const catchIdx = alwaysBlock.lastIndexOf('} catch (err) {')
143
- const reasonIdx = alwaysBlock.indexOf('grantFailReason = (err as Error).message', catchIdx)
144
- expect(catchIdx).toBeGreaterThan(-1)
145
- expect(reasonIdx).toBeGreaterThan(catchIdx)
139
+ it('captures a failure reason for the gateway log', () => {
140
+ expect(alwaysBlock).toContain('failReason')
141
+ expect(alwaysBlock).toContain('(err as Error).message')
146
142
  })
147
143
  })
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Tests for the pure diff synthesizer that powers the durable
3
+ * "๐Ÿ” Always allow" flow (#1977).
4
+ *
5
+ * Two layers:
6
+ * 1. Unit โ€” synthesizeAllowRuleDiff covers the three structural
7
+ * cases (flow list, block sequence, absent tools.allow), only
8
+ * touches the target agent in a multi-agent file, and returns
9
+ * null when the agent is absent. extractAddedAllowRule round-trips.
10
+ * 2. End-to-end โ€” feed each synthesized diff + a realistic, fully
11
+ * schema-valid fixture switchroom.yaml through `validateConfigEdit`
12
+ * (the real hostd validation pipeline, which runs
13
+ * `git apply --recount` then zod) and assert ok===true and the
14
+ * rule lands under the right agent. This is the load-bearing proof
15
+ * that the synthesizer emits git-apply-compatible diffs with
16
+ * byte-matching context lines AND that the post-apply yaml still
17
+ * validates. Requires `git` on PATH.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest'
21
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
22
+ import { tmpdir } from 'node:os'
23
+ import { join } from 'node:path'
24
+ import { parse as parseYaml } from 'yaml'
25
+ import {
26
+ synthesizeAllowRuleDiff,
27
+ extractAddedAllowRule,
28
+ } from '../permission-diff.js'
29
+ import { validateConfigEdit } from '../../src/host-control/config-edit-validator.js'
30
+
31
+ const TARGET = '/state/config/switchroom.yaml'
32
+
33
+ /**
34
+ * A schema-valid switchroom.yaml header. The validator runs the post-
35
+ * apply content through the real SwitchroomConfigSchema (zod), so the
36
+ * fixtures must be complete configs, not just `agents:` fragments.
37
+ */
38
+ const HEADER = [
39
+ 'switchroom:',
40
+ ' version: 1',
41
+ 'telegram:',
42
+ " bot_token: vault:telegram-bot-token",
43
+ " forum_chat_id: '-1001234567890'",
44
+ ].join('\n')
45
+
46
+ /** Build a complete config from an `agents:` body. */
47
+ function cfgWith(agentsBody: string): string {
48
+ return `${HEADER}\nagents:\n${agentsBody}\n`
49
+ }
50
+
51
+ /** Run the synthesized diff through the real hostd validator + apply. */
52
+ function applyViaValidator(configText: string, unifiedDiff: string) {
53
+ const dir = mkdtempSync(join(tmpdir(), 'perm-diff-'))
54
+ const cfgPath = join(dir, 'switchroom.yaml')
55
+ try {
56
+ writeFileSync(cfgPath, configText)
57
+ return validateConfigEdit({
58
+ configPath: cfgPath,
59
+ targetPath: TARGET,
60
+ unifiedDiff,
61
+ })
62
+ } finally {
63
+ rmSync(dir, { recursive: true, force: true })
64
+ }
65
+ }
66
+
67
+ function allowListFor(yamlText: string, agent: string): string[] {
68
+ const data = parseYaml(yamlText) as {
69
+ agents?: Record<string, { tools?: { allow?: string[] } }>
70
+ }
71
+ return data.agents?.[agent]?.tools?.allow ?? []
72
+ }
73
+
74
+ describe('synthesizeAllowRuleDiff โ€” structural cases', () => {
75
+ it('(a) flow list with elements: appends before the closing ]', () => {
76
+ const cfg = cfgWith(
77
+ [
78
+ ' clerk:',
79
+ ' topic_name: clerk',
80
+ ' purpose: clerk',
81
+ ' tools:',
82
+ ' allow: [Read, Grep]',
83
+ ' model: opus',
84
+ ].join('\n'),
85
+ )
86
+ const diff = synthesizeAllowRuleDiff({ agentName: 'clerk', rule: 'Bash', configText: cfg })
87
+ expect(diff).not.toBeNull()
88
+ expect(diff).toContain('--- a/switchroom.yaml')
89
+ expect(diff).toContain('+++ b/switchroom.yaml')
90
+ const res = applyViaValidator(cfg, diff!)
91
+ expect(res).toMatchObject({ ok: true })
92
+ if (res.ok) {
93
+ expect(allowListFor(res.postApplyContent, 'clerk')).toEqual(['Read', 'Grep', 'Bash'])
94
+ }
95
+ })
96
+
97
+ it('(a) flow list "[ all ]": appends, all preserved', () => {
98
+ const cfg = cfgWith(
99
+ [
100
+ ' ziggy:',
101
+ ' topic_name: ziggy',
102
+ ' purpose: ziggy',
103
+ ' tools:',
104
+ ' allow: [ all ]',
105
+ ' model: opus',
106
+ ].join('\n'),
107
+ )
108
+ const diff = synthesizeAllowRuleDiff({ agentName: 'ziggy', rule: 'Skill(mail)', configText: cfg })
109
+ expect(diff).not.toBeNull()
110
+ const res = applyViaValidator(cfg, diff!)
111
+ expect(res).toMatchObject({ ok: true })
112
+ if (res.ok) {
113
+ const allow = allowListFor(res.postApplyContent, 'ziggy')
114
+ expect(allow).toContain('all')
115
+ expect(allow).toContain('Skill(mail)')
116
+ }
117
+ })
118
+
119
+ it('(b) block sequence: inserts after the last - entry', () => {
120
+ const cfg = cfgWith(
121
+ [
122
+ ' klanker:',
123
+ ' topic_name: klanker',
124
+ ' purpose: klanker',
125
+ ' tools:',
126
+ ' allow:',
127
+ ' - Bash',
128
+ ' - Read',
129
+ ' model: opus',
130
+ ].join('\n'),
131
+ )
132
+ const diff = synthesizeAllowRuleDiff({ agentName: 'klanker', rule: 'mcp__notion__search', configText: cfg })
133
+ expect(diff).not.toBeNull()
134
+ const res = applyViaValidator(cfg, diff!)
135
+ expect(res).toMatchObject({ ok: true })
136
+ if (res.ok) {
137
+ expect(allowListFor(res.postApplyContent, 'klanker')).toEqual(['Bash', 'Read', 'mcp__notion__search'])
138
+ }
139
+ })
140
+
141
+ it('(c) tools.allow absent: inserts tools/allow/rule under the agent', () => {
142
+ const cfg = cfgWith(
143
+ [
144
+ ' carrie:',
145
+ ' topic_name: carrie',
146
+ ' purpose: carrie',
147
+ ' model: sonnet',
148
+ ' role: assistant',
149
+ ].join('\n'),
150
+ )
151
+ const diff = synthesizeAllowRuleDiff({ agentName: 'carrie', rule: 'WebFetch', configText: cfg })
152
+ expect(diff).not.toBeNull()
153
+ const res = applyViaValidator(cfg, diff!)
154
+ expect(res).toMatchObject({ ok: true })
155
+ if (res.ok) {
156
+ expect(allowListFor(res.postApplyContent, 'carrie')).toEqual(['WebFetch'])
157
+ }
158
+ })
159
+
160
+ it('(c) tools present but no allow: key: inserts allow block under tools', () => {
161
+ const cfg = cfgWith(
162
+ [
163
+ ' finn:',
164
+ ' topic_name: finn',
165
+ ' purpose: finn',
166
+ ' tools:',
167
+ ' deny:',
168
+ ' - Bash',
169
+ ' model: opus',
170
+ ].join('\n'),
171
+ )
172
+ const diff = synthesizeAllowRuleDiff({ agentName: 'finn', rule: 'Read', configText: cfg })
173
+ expect(diff).not.toBeNull()
174
+ const res = applyViaValidator(cfg, diff!)
175
+ expect(res).toMatchObject({ ok: true })
176
+ if (res.ok) {
177
+ expect(allowListFor(res.postApplyContent, 'finn')).toEqual(['Read'])
178
+ }
179
+ })
180
+
181
+ it('multi-agent: only the target agent is touched', () => {
182
+ const cfg = cfgWith(
183
+ [
184
+ ' clerk:',
185
+ ' topic_name: clerk',
186
+ ' purpose: clerk',
187
+ ' tools:',
188
+ ' allow:',
189
+ ' - Read',
190
+ ' ziggy:',
191
+ ' topic_name: ziggy',
192
+ ' purpose: ziggy',
193
+ ' tools:',
194
+ ' allow:',
195
+ ' - Bash',
196
+ ' reggie:',
197
+ ' topic_name: reggie',
198
+ ' purpose: reggie',
199
+ ' tools:',
200
+ ' allow: [Grep]',
201
+ ].join('\n'),
202
+ )
203
+ const diff = synthesizeAllowRuleDiff({ agentName: 'ziggy', rule: 'Write', configText: cfg })
204
+ expect(diff).not.toBeNull()
205
+ const res = applyViaValidator(cfg, diff!)
206
+ expect(res).toMatchObject({ ok: true })
207
+ if (res.ok) {
208
+ expect(allowListFor(res.postApplyContent, 'clerk')).toEqual(['Read'])
209
+ expect(allowListFor(res.postApplyContent, 'ziggy')).toEqual(['Bash', 'Write'])
210
+ expect(allowListFor(res.postApplyContent, 'reggie')).toEqual(['Grep'])
211
+ }
212
+ })
213
+
214
+ it('returns null when the agent is absent', () => {
215
+ const cfg = cfgWith(
216
+ [' clerk:', ' topic_name: clerk',
217
+ ' purpose: clerk', ' tools:', ' allow: [Read]'].join('\n'),
218
+ )
219
+ expect(synthesizeAllowRuleDiff({ agentName: 'nope', rule: 'Bash', configText: cfg })).toBeNull()
220
+ })
221
+
222
+ it('returns null when there is no agents block at all', () => {
223
+ expect(
224
+ synthesizeAllowRuleDiff({ agentName: 'clerk', rule: 'Bash', configText: 'defaults:\n model: opus\n' }),
225
+ ).toBeNull()
226
+ })
227
+ })
228
+
229
+ describe('extractAddedAllowRule โ€” round-trips the synthesized diff', () => {
230
+ it('block-sequence add', () => {
231
+ const cfg = cfgWith(
232
+ [' clerk:', ' topic_name: clerk',
233
+ ' purpose: clerk', ' tools:', ' allow:', ' - Bash', ' - Read'].join('\n'),
234
+ )
235
+ const diff = synthesizeAllowRuleDiff({ agentName: 'clerk', rule: 'mcp__x__y', configText: cfg })!
236
+ expect(extractAddedAllowRule(diff)).toBe('mcp__x__y')
237
+ })
238
+
239
+ it('flow-list append', () => {
240
+ const cfg = cfgWith([' clerk:', ' topic_name: clerk',
241
+ ' purpose: clerk', ' tools:', ' allow: [Read, Grep]', ' model: opus'].join('\n'))
242
+ const diff = synthesizeAllowRuleDiff({ agentName: 'clerk', rule: 'Bash', configText: cfg })!
243
+ expect(extractAddedAllowRule(diff)).toBe('Bash')
244
+ })
245
+
246
+ it('flow-list append into [ all ]', () => {
247
+ const cfg = cfgWith([' clerk:', ' topic_name: clerk',
248
+ ' purpose: clerk', ' tools:', ' allow: [ all ]', ' model: opus'].join('\n'))
249
+ const diff = synthesizeAllowRuleDiff({ agentName: 'clerk', rule: 'Skill(mail)', configText: cfg })!
250
+ expect(extractAddedAllowRule(diff)).toBe('Skill(mail)')
251
+ })
252
+
253
+ it('absent tools.allow (case c) โ€” extracts the single added rule', () => {
254
+ const cfg = cfgWith([' carrie:', ' topic_name: carrie',
255
+ ' purpose: carrie', ' model: sonnet', ' role: assistant'].join('\n'))
256
+ const diff = synthesizeAllowRuleDiff({ agentName: 'carrie', rule: 'WebFetch', configText: cfg })!
257
+ expect(extractAddedAllowRule(diff)).toBe('WebFetch')
258
+ })
259
+
260
+ it('returns null for a diff that adds something other than a tools.allow rule', () => {
261
+ // A hand-built diff that changes a `model:` field โ€” must NOT be
262
+ // mistaken for an allow-rule add (forge-resistance).
263
+ const forged = [
264
+ '--- a/switchroom.yaml',
265
+ '+++ b/switchroom.yaml',
266
+ '@@ -1,3 +1,3 @@',
267
+ ' agents:',
268
+ ' clerk:',
269
+ '- model: sonnet',
270
+ '+ model: opus',
271
+ '',
272
+ ].join('\n')
273
+ expect(extractAddedAllowRule(forged)).toBeNull()
274
+ })
275
+
276
+ it('returns null for a multi-rule diff (strict single-add only)', () => {
277
+ const forged = [
278
+ '--- a/switchroom.yaml',
279
+ '+++ b/switchroom.yaml',
280
+ '@@ -1,2 +1,4 @@',
281
+ ' agents:',
282
+ ' clerk:',
283
+ '+ - Bash',
284
+ '+ - Read',
285
+ '',
286
+ ].join('\n')
287
+ expect(extractAddedAllowRule(forged)).toBeNull()
288
+ })
289
+
290
+ it('returns null for empty input', () => {
291
+ expect(extractAddedAllowRule('')).toBeNull()
292
+ })
293
+ })
294
+
295
+ // The auto-approve correlation in gateway.ts gates on an EXACT byte-match
296
+ // of the incoming diff against the diff the gateway synthesized โ€” NOT on
297
+ // the rule token alone. This documents why: extractAddedAllowRule is
298
+ // location-blind (it returns the token for a `- <rule>` line under ANY
299
+ // key), so a forged edit placing the same consented token under `deny:`
300
+ // or `secrets:` yields the same token but a DIFFERENT diff string. The
301
+ // exact-diff gate is what rejects it.
302
+ describe('forge-resistance โ€” same token, wrong location โ‰  synthesized diff', () => {
303
+ const cfg = [
304
+ 'agents:',
305
+ ' clerk:',
306
+ ' tools:',
307
+ ' allow:',
308
+ ' - Read',
309
+ ' deny:',
310
+ ' - WebFetch',
311
+ '',
312
+ ].join('\n')
313
+
314
+ it('a deny-block diff adding the same token has the same token but a different diff', () => {
315
+ const legit = synthesizeAllowRuleDiff({ agentName: 'clerk', rule: 'Bash', configText: cfg })
316
+ expect(legit).not.toBeNull()
317
+ // The legit diff adds `- Bash` under tools.allow.
318
+ expect(extractAddedAllowRule(legit!)).toBe('Bash')
319
+
320
+ // A forged diff placing `- Bash` under the deny: block. Same token...
321
+ const forged = [
322
+ '--- a/switchroom.yaml',
323
+ '+++ b/switchroom.yaml',
324
+ '@@ -6,2 +6,3 @@',
325
+ ' deny:',
326
+ ' - WebFetch',
327
+ '+ - Bash',
328
+ '',
329
+ ].join('\n')
330
+ expect(extractAddedAllowRule(forged)).toBe('Bash') // ...location-blind: same token
331
+
332
+ // ...but the byte strings differ, so the exact-diff correlation gate
333
+ // (entry.unifiedDiff === msg.unifiedDiff) rejects the forgery.
334
+ expect(forged).not.toBe(legit)
335
+ })
336
+ })
@@ -288,17 +288,17 @@ describe("registerAndRender โ€” ergonomic full-pipeline call", () => {
288
288
  });
289
289
  });
290
290
 
291
- describe("appendActivityLine + renderActivityFeed โ€” accumulating draft feed", () => {
292
- it("accumulates distinct actions chronologically (newest last)", () => {
291
+ describe("appendActivityLine + renderActivityFeed โ€” accumulating activity feed", () => {
292
+ it("accumulates distinct actions chronologically (newest = current โ†’ bold, earlier = done โœ“ italic)", () => {
293
293
  const lines: string[] = [];
294
294
  expect(appendActivityLine(lines, "Read", { file_path: "a/gateway.ts" })).toBe(
295
- "ยท Reading gateway.ts",
295
+ "<b>โ†’ Reading gateway.ts</b>",
296
296
  );
297
297
  expect(appendActivityLine(lines, "mcp__hindsight__reflect", { query: "x" })).toBe(
298
- "ยท Reading gateway.ts\nยท Searching memory",
298
+ "<i>โœ“ Reading gateway.ts</i>\n<b>โ†’ Searching memory</b>",
299
299
  );
300
300
  expect(appendActivityLine(lines, "Bash", { command: "ls", description: "List workspace" })).toBe(
301
- "ยท Reading gateway.ts\nยท Searching memory\nยท List workspace",
301
+ "<i>โœ“ Reading gateway.ts</i>\n<i>โœ“ Searching memory</i>\n<b>โ†’ List workspace</b>",
302
302
  );
303
303
  });
304
304
 
@@ -315,14 +315,26 @@ describe("appendActivityLine + renderActivityFeed โ€” accumulating draft feed",
315
315
  expect(lines).toEqual([]);
316
316
  });
317
317
 
318
- it("caps to the last MIRROR_MAX_LINES with a '+N earlier' header", () => {
318
+ it("caps to the last MIRROR_MAX_LINES with a 'โœ“ +N earlierโ€ฆ' header", () => {
319
319
  const lines = Array.from({ length: 9 }, (_, i) => `Action ${i + 1}`);
320
320
  const out = renderActivityFeed(lines)!;
321
- expect(out.startsWith("ยท +3 earlierโ€ฆ\n")).toBe(true);
322
- // Only the last 6 actions are shown.
323
- expect(out).toContain("ยท Action 4");
324
- expect(out).toContain("ยท Action 9");
325
- expect(out).not.toContain("ยท Action 3\n");
321
+ expect(out.startsWith("<i>โœ“ +3 earlierโ€ฆ</i>\n")).toBe(true);
322
+ // Only the last 6 actions are shown; the oldest 3 are collapsed.
323
+ expect(out).toContain("<i>โœ“ Action 4</i>");
324
+ expect(out).not.toContain("Action 3");
325
+ // The newest action is the in-progress step (bold โ†’); the rest are done (โœ“).
326
+ expect(out).toContain("<b>โ†’ Action 9</b>");
327
+ expect(out).toContain("<i>โœ“ Action 8</i>");
328
+ expect(out).not.toContain("<b>โ†’ Action 8</b>");
329
+ });
330
+
331
+ it("HTML-escapes &, <, > in action text (no double-escaping by callers)", () => {
332
+ const out = renderActivityFeed(["Running <foo> & <bar>"])!;
333
+ expect(out).toBe("<b>โ†’ Running &lt;foo&gt; &amp; &lt;bar&gt;</b>");
334
+ });
335
+
336
+ it("renders a single line as the current (bold โ†’) step", () => {
337
+ expect(renderActivityFeed(["Reading a.ts"])).toBe("<b>โ†’ Reading a.ts</b>");
326
338
  });
327
339
 
328
340
  it("renderActivityFeed returns null on empty", () => {
@@ -333,9 +345,9 @@ describe("appendActivityLine + renderActivityFeed โ€” accumulating draft feed",
333
345
  describe("appendActivityLabel โ€” precomputed label feed (tool_label path)", () => {
334
346
  it("accumulates precomputed labels, dedups consecutive, ignores empty", () => {
335
347
  const lines: string[] = [];
336
- expect(appendActivityLabel(lines, "Searching memory")).toBe("ยท Searching memory");
348
+ expect(appendActivityLabel(lines, "Searching memory")).toBe("<b>โ†’ Searching memory</b>");
337
349
  expect(appendActivityLabel(lines, "List workspace")).toBe(
338
- "ยท Searching memory\nยท List workspace",
350
+ "<i>โœ“ Searching memory</i>\n<b>โ†’ List workspace</b>",
339
351
  );
340
352
  // consecutive dup collapses
341
353
  appendActivityLabel(lines, "List workspace");