switchroom 0.14.12 β 0.14.13
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 +13 -11
- package/dist/host-control/main.js +80 -6
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +61 -8
- package/telegram-plugin/dist/gateway/gateway.js +283 -161
- package/telegram-plugin/dist/server.js +64 -9
- package/telegram-plugin/gateway/gateway.ts +78 -66
- package/telegram-plugin/gateway/ipc-protocol.ts +4 -2
- package/telegram-plugin/permission-rule.ts +200 -122
- package/telegram-plugin/permission-title.ts +209 -197
- package/telegram-plugin/tests/always-allow-grant.test.ts +86 -54
- package/telegram-plugin/tests/always-allow-persist.test.ts +35 -34
- package/telegram-plugin/tests/permission-rule.test.ts +185 -127
- package/telegram-plugin/tests/permission-title.test.ts +109 -195
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Structural contract tests for the durable "π Always allow" handler
|
|
3
|
-
* in gateway.ts
|
|
4
|
-
*
|
|
3
|
+
* in gateway.ts, reworked for #1977 and the scoped-card split.
|
|
4
|
+
*
|
|
5
|
+
* The "Alwaysβ¦" flow is now TWO callback steps:
|
|
6
|
+
* - `behavior === 'always' | 'back'` β swaps the action row for the
|
|
7
|
+
* scope sub-menu (and back). Dispatches NO verdict and touches NO
|
|
8
|
+
* host config; it only edits the keyboard.
|
|
9
|
+
* - `behavior === 'asn' | 'asb'` β the operator picked a scope
|
|
10
|
+
* (narrow / broad). THIS is where the verdict fires and the durable
|
|
11
|
+
* persistence round-trip runs.
|
|
5
12
|
*
|
|
6
13
|
* Why structural: the handler lives inside a Grammy callback closure
|
|
7
14
|
* that's not exported. Full-function invocation would require a complete
|
|
8
15
|
* Grammy + hostd + switchroomExec harness. The behavioural pieces are
|
|
9
|
-
* covered by the pure-function tests (permission-diff.test.ts
|
|
10
|
-
*
|
|
11
|
-
* file pins the source-level
|
|
16
|
+
* covered by the pure-function tests (permission-diff.test.ts,
|
|
17
|
+
* permission-rule.test.ts) and the correlation handler tests
|
|
18
|
+
* (always-allow-correlation.test.ts); this file pins the source-level
|
|
19
|
+
* invariants of the orchestration block.
|
|
12
20
|
*
|
|
13
|
-
* Post-#1977 contract:
|
|
21
|
+
* Post-#1977 contract (now on the scope-commit `asn`/`asb` block):
|
|
14
22
|
* 1. The in-flight Allow verdict fires IMMEDIATELY (before awaiting
|
|
15
23
|
* hostd) so the turn never blocks on host-config persistence.
|
|
16
24
|
* 2. Durable persistence goes through hostd's `config_propose_edit`
|
|
@@ -19,9 +27,6 @@
|
|
|
19
27
|
* not-configured fallback ONLY (not the primary path), and it
|
|
20
28
|
* still verifies via isRulePersisted with honest messaging.
|
|
21
29
|
* 4. Failure text never falsely claims durable success.
|
|
22
|
-
*
|
|
23
|
-
* Slicing strategy: we extract the `if (behavior === 'always') {` block
|
|
24
|
-
* from gateway.ts and run string assertions against that slice only.
|
|
25
30
|
*/
|
|
26
31
|
|
|
27
32
|
import { describe, it, expect } from 'vitest'
|
|
@@ -34,110 +39,137 @@ const gatewaySrc = readFileSync(
|
|
|
34
39
|
)
|
|
35
40
|
|
|
36
41
|
/**
|
|
37
|
-
* Extract the `behavior === '
|
|
38
|
-
* dispatcher. The slice runs from
|
|
39
|
-
* up to (but not including) the
|
|
42
|
+
* Extract the scope-commit block (`behavior === 'asn' || behavior ===
|
|
43
|
+
* 'asb'`) from the perm: callback dispatcher. The slice runs from that
|
|
44
|
+
* guard up to (but not including) the `// Forward permission decision`
|
|
40
45
|
* comment which opens the allow/deny branch.
|
|
41
46
|
*/
|
|
42
|
-
function
|
|
43
|
-
const start = gatewaySrc.indexOf("if (behavior === '
|
|
47
|
+
function sliceCommitBlock(): string {
|
|
48
|
+
const start = gatewaySrc.indexOf("if (behavior === 'asn' || behavior === 'asb')")
|
|
44
49
|
const end = gatewaySrc.indexOf('// Forward permission decision to connected bridges', start)
|
|
45
50
|
if (start === -1 || end === -1) return ''
|
|
46
51
|
return gatewaySrc.slice(start, end)
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Extract the keyboard-toggle block (`behavior === 'always' || behavior
|
|
56
|
+
* === 'back'`), which must NOT dispatch a verdict.
|
|
57
|
+
*/
|
|
58
|
+
function sliceToggleBlock(): string {
|
|
59
|
+
const start = gatewaySrc.indexOf("if (behavior === 'always' || behavior === 'back')")
|
|
60
|
+
const end = gatewaySrc.indexOf("if (behavior === 'asn' || behavior === 'asb')", start)
|
|
61
|
+
if (start === -1 || end === -1) return ''
|
|
62
|
+
return gatewaySrc.slice(start, end)
|
|
63
|
+
}
|
|
50
64
|
|
|
51
|
-
|
|
65
|
+
const commitBlock = sliceCommitBlock()
|
|
66
|
+
const toggleBlock = sliceToggleBlock()
|
|
67
|
+
|
|
68
|
+
describe('always-allow β keyboard toggle block (no side effects)', () => {
|
|
69
|
+
it('exists and only edits the reply markup', () => {
|
|
70
|
+
expect(toggleBlock).not.toBe('')
|
|
71
|
+
expect(toggleBlock).toContain('editMessageReplyMarkup')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('dispatches NO verdict and runs NO host round-trip', () => {
|
|
75
|
+
expect(toggleBlock).not.toContain('dispatchPermissionVerdict(')
|
|
76
|
+
expect(toggleBlock).not.toContain('tryHostdDispatch(')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('surfaces the scope choice only now (specific + broad-with-β οΈ)', () => {
|
|
80
|
+
expect(toggleBlock).toContain('choices.broad.buttonLabel')
|
|
81
|
+
expect(toggleBlock).toContain('β οΈ')
|
|
82
|
+
expect(toggleBlock).toContain('perm:asn:')
|
|
83
|
+
expect(toggleBlock).toContain('perm:asb:')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('scope-commit β immediate verdict dispatch (turn must not block)', () => {
|
|
52
88
|
it('dispatches the permission verdict BEFORE the hostd await', () => {
|
|
53
|
-
const verdictIdx =
|
|
54
|
-
const hostdAwaitIdx =
|
|
89
|
+
const verdictIdx = commitBlock.indexOf('dispatchPermissionVerdict({')
|
|
90
|
+
const hostdAwaitIdx = commitBlock.indexOf('await tryHostdDispatch(')
|
|
55
91
|
expect(verdictIdx).toBeGreaterThan(-1)
|
|
56
92
|
expect(hostdAwaitIdx).toBeGreaterThan(-1)
|
|
57
|
-
// The verdict fires first β independent of the durable persistence
|
|
58
|
-
// round-trip.
|
|
59
93
|
expect(verdictIdx).toBeLessThan(hostdAwaitIdx)
|
|
60
94
|
})
|
|
61
95
|
|
|
62
|
-
it('carries the
|
|
63
|
-
expect(
|
|
64
|
-
expect(
|
|
96
|
+
it('carries the chosen scope rule on the verdict so the bridge caches it', () => {
|
|
97
|
+
expect(commitBlock).toContain("behavior: 'allow'")
|
|
98
|
+
expect(commitBlock).toContain('rule: chosen.rule')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('resolves the chosen scope from the tapped button (asn=narrow, asb=broad)', () => {
|
|
102
|
+
expect(commitBlock).toContain('resolveScopedAllowChoices(')
|
|
103
|
+
expect(commitBlock).toContain("behavior === 'asn' ? (choices.specific ?? choices.broad) : choices.broad")
|
|
65
104
|
})
|
|
66
105
|
|
|
67
106
|
it('does NOT pass a synthInbound to finalizeCallback (verdict already fired)', () => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const finalizeIdx = alwaysBlock.indexOf('await finalizeCallback(ctx, {')
|
|
71
|
-
const after = alwaysBlock.slice(finalizeIdx)
|
|
107
|
+
const finalizeIdx = commitBlock.indexOf('await finalizeCallback(ctx, {')
|
|
108
|
+
const after = commitBlock.slice(finalizeIdx)
|
|
72
109
|
expect(finalizeIdx).toBeGreaterThan(-1)
|
|
73
110
|
expect(after).not.toContain('synthInbound')
|
|
74
111
|
})
|
|
75
112
|
})
|
|
76
113
|
|
|
77
|
-
describe('
|
|
114
|
+
describe('scope-commit β durable hostd persistence', () => {
|
|
78
115
|
it('synthesizes a unified diff from the raw config text', () => {
|
|
79
|
-
expect(
|
|
80
|
-
expect(
|
|
116
|
+
expect(commitBlock).toContain('synthesizeAllowRuleDiff({')
|
|
117
|
+
expect(commitBlock).toContain("op: 'config_propose_edit'")
|
|
81
118
|
})
|
|
82
119
|
|
|
83
120
|
it('reads the RAW config bytes (readFileSync), not the parsed config', () => {
|
|
84
|
-
|
|
85
|
-
// read literal bytes rather than re-serialising loadSwitchroomConfig.
|
|
86
|
-
expect(alwaysBlock).toContain('readFileSync(')
|
|
121
|
+
expect(commitBlock).toContain('readFileSync(')
|
|
87
122
|
})
|
|
88
123
|
|
|
89
124
|
it('passes a long timeout to tryHostdDispatch (apply+reconcile blocks)', () => {
|
|
90
|
-
expect(
|
|
125
|
+
expect(commitBlock).toContain('await tryHostdDispatch(agentName, req, 60_000)')
|
|
91
126
|
})
|
|
92
127
|
|
|
93
128
|
it('registers + cleans up the single-tap correlation entry', () => {
|
|
94
|
-
expect(
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
const deleteIdx = alwaysBlock.indexOf('pendingAlwaysAllowCorrelations.delete(correlationKey)', finallyIdx)
|
|
129
|
+
expect(commitBlock).toContain('pendingAlwaysAllowCorrelations.set(correlationKey')
|
|
130
|
+
const finallyIdx = commitBlock.indexOf('} finally {')
|
|
131
|
+
const deleteIdx = commitBlock.indexOf('pendingAlwaysAllowCorrelations.delete(correlationKey)', finallyIdx)
|
|
98
132
|
expect(finallyIdx).toBeGreaterThan(-1)
|
|
99
133
|
expect(deleteIdx).toBeGreaterThan(finallyIdx)
|
|
100
134
|
})
|
|
101
135
|
|
|
102
136
|
it('treats E_CONFIG_EDIT_DISABLED specially (no legacy fallback, points at the flag)', () => {
|
|
103
|
-
expect(
|
|
104
|
-
expect(
|
|
137
|
+
expect(commitBlock).toContain('E_CONFIG_EDIT_DISABLED')
|
|
138
|
+
expect(commitBlock).toContain('hostd.config_edit_enabled')
|
|
105
139
|
})
|
|
106
140
|
})
|
|
107
141
|
|
|
108
|
-
describe('
|
|
142
|
+
describe('scope-commit β legacy fallback ONLY when hostd not-configured', () => {
|
|
109
143
|
it('falls back to switchroom agent grant only on not-configured', () => {
|
|
110
|
-
const notConfiguredIdx =
|
|
111
|
-
const grantIdx =
|
|
144
|
+
const notConfiguredIdx = commitBlock.indexOf("resp === 'not-configured'")
|
|
145
|
+
const grantIdx = commitBlock.indexOf("switchroomExec(['agent', 'grant'")
|
|
112
146
|
expect(notConfiguredIdx).toBeGreaterThan(-1)
|
|
113
147
|
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
148
|
expect(grantIdx).toBeGreaterThan(notConfiguredIdx)
|
|
117
149
|
})
|
|
118
150
|
|
|
119
151
|
it('emits the legacy-spawn deprecation warning on the not-configured path', () => {
|
|
120
|
-
expect(
|
|
152
|
+
expect(commitBlock).toContain("warnLegacySpawnIfHostdDisabled('always-allow')")
|
|
121
153
|
})
|
|
122
154
|
|
|
123
155
|
it('verifies the legacy write landed via isRulePersisted', () => {
|
|
124
|
-
expect(
|
|
125
|
-
expect(
|
|
156
|
+
expect(commitBlock).toContain('isRulePersisted(allowList, chosen.rule)')
|
|
157
|
+
expect(commitBlock).toContain('resolveAgentConfig(')
|
|
126
158
|
})
|
|
127
159
|
|
|
128
160
|
it('legacy success messaging is honest about being the legacy path', () => {
|
|
129
|
-
expect(
|
|
161
|
+
expect(commitBlock).toContain('(legacy path)')
|
|
130
162
|
})
|
|
131
163
|
})
|
|
132
164
|
|
|
133
|
-
describe('
|
|
165
|
+
describe('scope-commit β loud failure invariants', () => {
|
|
134
166
|
it('failure text uses the β οΈ warning marker, never a false β
success', () => {
|
|
135
|
-
expect(
|
|
136
|
-
expect(
|
|
167
|
+
expect(commitBlock).toContain('did NOT save')
|
|
168
|
+
expect(commitBlock).not.toContain('β
Allowed (always-allow yaml edit failed')
|
|
137
169
|
})
|
|
138
170
|
|
|
139
171
|
it('captures a failure reason for the gateway log', () => {
|
|
140
|
-
expect(
|
|
141
|
-
expect(
|
|
172
|
+
expect(commitBlock).toContain('failReason')
|
|
173
|
+
expect(commitBlock).toContain('(err as Error).message')
|
|
142
174
|
})
|
|
143
175
|
})
|
|
@@ -23,7 +23,14 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { describe, it, expect } from 'vitest'
|
|
26
|
-
import { isRulePersisted,
|
|
26
|
+
import { isRulePersisted, resolveScopedAllowChoices } from '../permission-rule.js'
|
|
27
|
+
|
|
28
|
+
/** The rule the handler dispatches when the operator taps a scope button. */
|
|
29
|
+
const specificRule = (tool: string, input: string | undefined): string => {
|
|
30
|
+
const choices = resolveScopedAllowChoices(tool, input)
|
|
31
|
+
expect(choices).not.toBeNull()
|
|
32
|
+
return (choices!.specific ?? choices!.broad).rule
|
|
33
|
+
}
|
|
27
34
|
|
|
28
35
|
// ---------------------------------------------------------------------------
|
|
29
36
|
// Core behavioral invariants
|
|
@@ -69,56 +76,50 @@ describe('isRulePersisted β success path', () => {
|
|
|
69
76
|
})
|
|
70
77
|
|
|
71
78
|
// ---------------------------------------------------------------------------
|
|
72
|
-
// Round-trip:
|
|
73
|
-
// Simulates the full handler flow: resolve the
|
|
74
|
-
// "grant" it (allow-list contains the resolved rule
|
|
79
|
+
// Round-trip: resolveScopedAllowChoices β isRulePersisted
|
|
80
|
+
// Simulates the full handler flow: resolve the scope the operator tapped,
|
|
81
|
+
// "grant" it (allow-list contains the resolved rule), then verify.
|
|
75
82
|
// Guards against normalization divergence between the value the handler
|
|
76
|
-
//
|
|
83
|
+
// dispatches and the value `agent grant`/hostd writes + the config reader
|
|
84
|
+
// returns.
|
|
77
85
|
// ---------------------------------------------------------------------------
|
|
78
86
|
|
|
79
87
|
describe('rule round-trip through isRulePersisted', () => {
|
|
80
|
-
it('Skill tool:
|
|
81
|
-
const rule =
|
|
82
|
-
expect(rule).not.toBeNull()
|
|
83
|
-
// Simulate: allow-list now contains the rule that `agent grant` wrote.
|
|
84
|
-
expect(isRulePersisted([rule!.rule], rule!.rule)).toBe(true)
|
|
88
|
+
it('Skill tool: the specific (this-skill) rule persists correctly', () => {
|
|
89
|
+
const rule = specificRule('Skill', JSON.stringify({ skill: 'garmin' }))
|
|
85
90
|
// Confirm the written form is `Skill(garmin)` β not a bare `Skill`.
|
|
86
|
-
expect(rule
|
|
91
|
+
expect(rule).toBe('Skill(garmin)')
|
|
92
|
+
expect(isRulePersisted([rule], rule)).toBe(true)
|
|
87
93
|
})
|
|
88
94
|
|
|
89
95
|
it('Skill tool: absent rule is detected', () => {
|
|
90
|
-
const rule =
|
|
91
|
-
expect(rule).not.toBeNull()
|
|
96
|
+
const rule = specificRule('Skill', JSON.stringify({ skill: 'garmin' }))
|
|
92
97
|
// allow-list was not updated (silent grant failure).
|
|
93
|
-
expect(isRulePersisted([], rule
|
|
94
|
-
expect(isRulePersisted(['Skill'], rule
|
|
98
|
+
expect(isRulePersisted([], rule)).toBe(false)
|
|
99
|
+
expect(isRulePersisted(['Skill'], rule)).toBe(false)
|
|
95
100
|
})
|
|
96
101
|
|
|
97
|
-
it('Bash tool: round-trips correctly', () => {
|
|
98
|
-
const rule =
|
|
99
|
-
expect(rule).
|
|
100
|
-
expect(rule
|
|
101
|
-
expect(isRulePersisted(['
|
|
102
|
-
expect(isRulePersisted(['Read'], rule!.rule)).toBe(false)
|
|
102
|
+
it('Bash tool: the broad (any-command) rule round-trips correctly', () => {
|
|
103
|
+
const rule = resolveScopedAllowChoices('Bash', undefined)!.broad.rule
|
|
104
|
+
expect(rule).toBe('Bash')
|
|
105
|
+
expect(isRulePersisted(['Bash', 'Read'], rule)).toBe(true)
|
|
106
|
+
expect(isRulePersisted(['Read'], rule)).toBe(false)
|
|
103
107
|
})
|
|
104
108
|
|
|
105
|
-
it('MCP tool: round-trips with exact
|
|
109
|
+
it('MCP tool: the specific (this-action) rule round-trips with exact form', () => {
|
|
106
110
|
const toolName = 'mcp__garmin__list_activities'
|
|
107
|
-
const rule =
|
|
108
|
-
expect(rule).
|
|
109
|
-
expect(rule
|
|
110
|
-
expect(isRulePersisted([
|
|
111
|
-
expect(isRulePersisted(['mcp__garmin__read_activity'], rule!.rule)).toBe(false)
|
|
111
|
+
const rule = specificRule(toolName, undefined)
|
|
112
|
+
expect(rule).toBe(toolName)
|
|
113
|
+
expect(isRulePersisted([toolName], rule)).toBe(true)
|
|
114
|
+
expect(isRulePersisted(['mcp__garmin__read_activity'], rule)).toBe(false)
|
|
112
115
|
})
|
|
113
116
|
|
|
114
117
|
it('multiple Skill tools do not cross-contaminate', () => {
|
|
115
|
-
const garmin =
|
|
116
|
-
const mail =
|
|
117
|
-
expect(garmin).not.toBeNull()
|
|
118
|
-
expect(mail).not.toBeNull()
|
|
118
|
+
const garmin = specificRule('Skill', JSON.stringify({ skill: 'garmin' }))
|
|
119
|
+
const mail = specificRule('Skill', JSON.stringify({ skill: 'mail' }))
|
|
119
120
|
// Allow-list only has garmin's rule.
|
|
120
|
-
const allowList = [garmin
|
|
121
|
-
expect(isRulePersisted(allowList, garmin
|
|
122
|
-
expect(isRulePersisted(allowList, mail
|
|
121
|
+
const allowList = [garmin]
|
|
122
|
+
expect(isRulePersisted(allowList, garmin)).toBe(true)
|
|
123
|
+
expect(isRulePersisted(allowList, mail)).toBe(false)
|
|
123
124
|
})
|
|
124
125
|
})
|