switchroom 0.14.11 β†’ 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.
@@ -1,16 +1,24 @@
1
1
  /**
2
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.
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) and the
10
- * correlation handler tests (always-allow-correlation.test.ts); this
11
- * file pins the source-level invariants of the orchestration block.
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 === 'always'` block from the perm: callback
38
- * dispatcher. The slice runs from the `if (behavior === 'always')` guard
39
- * up to (but not including) the next top-level `// Forward permission`
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 sliceAlwaysBlock(): string {
43
- const start = gatewaySrc.indexOf("if (behavior === 'always')")
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
- const alwaysBlock = sliceAlwaysBlock()
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
- describe('always-allow handler β€” immediate verdict dispatch (turn must not block)', () => {
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 = alwaysBlock.indexOf('dispatchPermissionVerdict({')
54
- const hostdAwaitIdx = alwaysBlock.indexOf('await tryHostdDispatch(')
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 resolved rule on the verdict so the bridge caches it', () => {
63
- expect(alwaysBlock).toContain("behavior: 'allow'")
64
- expect(alwaysBlock).toContain('rule: rule.rule')
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
- // 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)
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('always-allow handler β€” durable hostd persistence', () => {
114
+ describe('scope-commit β€” durable hostd persistence', () => {
78
115
  it('synthesizes a unified diff from the raw config text', () => {
79
- expect(alwaysBlock).toContain('synthesizeAllowRuleDiff({')
80
- expect(alwaysBlock).toContain("op: 'config_propose_edit'")
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
- // 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(')
121
+ expect(commitBlock).toContain('readFileSync(')
87
122
  })
88
123
 
89
124
  it('passes a long timeout to tryHostdDispatch (apply+reconcile blocks)', () => {
90
- expect(alwaysBlock).toContain('await tryHostdDispatch(agentName, req, 60_000)')
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(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)
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(alwaysBlock).toContain('E_CONFIG_EDIT_DISABLED')
104
- expect(alwaysBlock).toContain('hostd.config_edit_enabled')
137
+ expect(commitBlock).toContain('E_CONFIG_EDIT_DISABLED')
138
+ expect(commitBlock).toContain('hostd.config_edit_enabled')
105
139
  })
106
140
  })
107
141
 
108
- describe('always-allow handler β€” legacy fallback ONLY when hostd not-configured', () => {
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 = alwaysBlock.indexOf("resp === 'not-configured'")
111
- const grantIdx = alwaysBlock.indexOf("switchroomExec(['agent', 'grant'")
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(alwaysBlock).toContain("warnLegacySpawnIfHostdDisabled('always-allow')")
152
+ expect(commitBlock).toContain("warnLegacySpawnIfHostdDisabled('always-allow')")
121
153
  })
122
154
 
123
155
  it('verifies the legacy write landed via isRulePersisted', () => {
124
- expect(alwaysBlock).toContain('isRulePersisted(allowList, rule.rule)')
125
- expect(alwaysBlock).toContain('resolveAgentConfig(')
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(alwaysBlock).toContain('(legacy path)')
161
+ expect(commitBlock).toContain('(legacy path)')
130
162
  })
131
163
  })
132
164
 
133
- describe('always-allow handler β€” loud failure invariants', () => {
165
+ describe('scope-commit β€” loud failure invariants', () => {
134
166
  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')
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(alwaysBlock).toContain('failReason')
141
- expect(alwaysBlock).toContain('(err as Error).message')
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, resolveAlwaysAllowRule } from '../permission-rule.js'
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: resolveAlwaysAllowRule β†’ isRulePersisted
73
- // Simulates the full handler flow: resolve the rule from a permission_request,
74
- // "grant" it (allow-list contains the resolved rule.rule), then verify.
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
- // resolves and the value `agent grant` writes + the config reader returns.
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: resolved rule persists correctly', () => {
81
- const rule = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'garmin' }))
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!.rule).toBe('Skill(garmin)')
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 = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'garmin' }))
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!.rule)).toBe(false)
94
- expect(isRulePersisted(['Skill'], rule!.rule)).toBe(false)
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 = resolveAlwaysAllowRule('Bash', undefined)
99
- expect(rule).not.toBeNull()
100
- expect(rule!.rule).toBe('Bash')
101
- expect(isRulePersisted(['Bash', 'Read'], rule!.rule)).toBe(true)
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 namespaced form', () => {
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 = resolveAlwaysAllowRule(toolName, undefined)
108
- expect(rule).not.toBeNull()
109
- expect(rule!.rule).toBe(toolName)
110
- expect(isRulePersisted([toolName], rule!.rule)).toBe(true)
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 = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'garmin' }))
116
- const mail = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: '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!.rule]
121
- expect(isRulePersisted(allowList, garmin!.rule)).toBe(true)
122
- expect(isRulePersisted(allowList, mail!.rule)).toBe(false)
121
+ const allowList = [garmin]
122
+ expect(isRulePersisted(allowList, garmin)).toBe(true)
123
+ expect(isRulePersisted(allowList, mail)).toBe(false)
123
124
  })
124
125
  })