switchroom 0.14.7 โ 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.
- package/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +395 -357
- package/dist/host-control/main.js +148 -148
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +23 -0
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +583 -284
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/config-approval-handler.ts +36 -0
- package/telegram-plugin/gateway/gateway.ts +296 -180
- package/telegram-plugin/gateway/hostd-dispatch.ts +2 -1
- package/telegram-plugin/permission-diff.ts +382 -0
- package/telegram-plugin/tests/always-allow-correlation.test.ts +147 -0
- package/telegram-plugin/tests/always-allow-grant.test.ts +84 -88
- package/telegram-plugin/tests/permission-diff.test.ts +336 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +25 -13
- package/telegram-plugin/tool-activity-summary.ts +27 -15
|
@@ -1,32 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Structural contract tests for the "๐ Always allow" handler
|
|
3
|
-
* gateway.ts (the `behavior === 'always'` branch of the perm:
|
|
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.
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 2.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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 โ
|
|
57
|
-
it('
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
expect(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
expect(
|
|
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('
|
|
68
|
-
// The
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
expect(
|
|
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 โ
|
|
78
|
-
it('
|
|
79
|
-
expect(alwaysBlock).toContain('
|
|
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('
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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('
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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('
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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('
|
|
113
|
-
|
|
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('
|
|
126
|
-
expect(alwaysBlock).toContain('
|
|
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('
|
|
130
|
-
expect(alwaysBlock).toContain('
|
|
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 โ
|
|
135
|
-
it('
|
|
136
|
-
expect(alwaysBlock).toContain('
|
|
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('
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
292
|
-
it("accumulates distinct actions chronologically (newest
|
|
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
|
-
"
|
|
295
|
+
"<b>โ Reading gateway.ts</b>",
|
|
296
296
|
);
|
|
297
297
|
expect(appendActivityLine(lines, "mcp__hindsight__reflect", { query: "x" })).toBe(
|
|
298
|
-
"
|
|
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
|
-
"
|
|
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("
|
|
322
|
-
// Only the last 6 actions are shown.
|
|
323
|
-
expect(out).toContain("
|
|
324
|
-
expect(out).toContain("
|
|
325
|
-
|
|
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 <foo> & <bar></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("
|
|
348
|
+
expect(appendActivityLabel(lines, "Searching memory")).toBe("<b>โ Searching memory</b>");
|
|
337
349
|
expect(appendActivityLabel(lines, "List workspace")).toBe(
|
|
338
|
-
"
|
|
350
|
+
"<i>โ Searching memory</i>\n<b>โ List workspace</b>",
|
|
339
351
|
);
|
|
340
352
|
// consecutive dup collapses
|
|
341
353
|
appendActivityLabel(lines, "List workspace");
|