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.
- package/dist/agent-scheduler/index.js +4 -0
- package/dist/auth-broker/index.js +4 -0
- package/dist/cli/notion-write-pretool.mjs +4 -0
- package/dist/cli/switchroom.js +255 -12
- package/dist/host-control/main.js +84 -6
- package/dist/vault/approvals/kernel-server.js +5 -1
- package/dist/vault/broker/server.js +5 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +61 -8
- package/telegram-plugin/dist/gateway/gateway.js +287 -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,228 +1,111 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
test('Bash: truncates long commands', () => {
|
|
11
|
-
const input = JSON.stringify({
|
|
12
|
-
command: 'find /var/log -name "*.log" -mtime -1 -exec gzip {} \\;',
|
|
13
|
-
})
|
|
14
|
-
const out = summarizeToolForTitle('Bash', input)
|
|
15
|
-
expect(out.startsWith('Bash: ')).toBe(true)
|
|
16
|
-
expect(out.length).toBeLessThanOrEqual(60)
|
|
17
|
-
expect(out.endsWith('…')).toBe(true)
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
test('Read/Edit/Write: shows basename only', () => {
|
|
21
|
-
const input = JSON.stringify({ file_path: '/long/absolute/path/to/server.ts' })
|
|
22
|
-
expect(summarizeToolForTitle('Read', input)).toBe('Read: server.ts')
|
|
23
|
-
expect(summarizeToolForTitle('Edit', input)).toBe('Edit: server.ts')
|
|
24
|
-
expect(summarizeToolForTitle('Write', input)).toBe('Write: server.ts')
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
test('Glob/Grep: surfaces the pattern', () => {
|
|
28
|
-
const input = JSON.stringify({ pattern: '**/*.ts' })
|
|
29
|
-
expect(summarizeToolForTitle('Glob', input)).toBe('Glob: **/*.ts')
|
|
30
|
-
expect(summarizeToolForTitle('Grep', input)).toBe('Grep: **/*.ts')
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
test('WebFetch: surfaces the URL', () => {
|
|
34
|
-
const input = JSON.stringify({ url: 'https://example.com/some/page' })
|
|
35
|
-
expect(summarizeToolForTitle('WebFetch', input)).toBe(
|
|
36
|
-
'WebFetch: https://example.com/some/page',
|
|
37
|
-
)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
test('falls back to bare toolName for unrecognised tools', () => {
|
|
41
|
-
expect(summarizeToolForTitle('SomeCustomTool', JSON.stringify({ x: 1 }))).toBe(
|
|
42
|
-
'SomeCustomTool',
|
|
43
|
-
)
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
test('falls back to bare toolName when input_preview is malformed', () => {
|
|
47
|
-
expect(summarizeToolForTitle('Skill', 'not-json')).toBe('Skill')
|
|
48
|
-
expect(summarizeToolForTitle('Skill', '')).toBe('Skill')
|
|
49
|
-
expect(summarizeToolForTitle('Skill', undefined)).toBe('Skill')
|
|
50
|
-
})
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the human-readable permission card text (#186, #1790, and
|
|
3
|
+
* the scoped-card work). Three surfaces:
|
|
4
|
+
* - `naturalAction` — the verb-phrase after "wants to" (no tool ids).
|
|
5
|
+
* - `formatPermissionCardBody` — the collapsed-view card body.
|
|
6
|
+
* - `describeGrant` — the after-the-fact confirmation, phrased from
|
|
7
|
+
* the *scope the operator chose*.
|
|
8
|
+
*/
|
|
51
9
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
10
|
+
import { describe, test, expect } from 'vitest'
|
|
11
|
+
import {
|
|
12
|
+
naturalAction,
|
|
13
|
+
describeGrant,
|
|
14
|
+
formatPermissionCardBody,
|
|
15
|
+
} from '../permission-title.js'
|
|
16
|
+
import type { ScopeOption } from '../permission-rule.js'
|
|
57
17
|
|
|
58
|
-
|
|
59
|
-
// skill-name key matched"; that produced operator-hostile cards like
|
|
60
|
-
// `🔐 Permission: Skill` with zero context. The Skill summarizer now
|
|
61
|
-
// tries `command`, then a first-scalar-arg hint, before giving up.
|
|
62
|
-
test('Skill: when no skill-name key matches, falls back to command field (#1790)', () => {
|
|
63
|
-
const input = JSON.stringify({ command: 'gen calendar event' })
|
|
64
|
-
expect(summarizeToolForTitle('Skill', input)).toBe('Skill: gen calendar event')
|
|
65
|
-
})
|
|
18
|
+
const opt = (rule: string): ScopeOption => ({ rule, buttonLabel: 'x', broad: false })
|
|
66
19
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
20
|
+
describe('naturalAction — built-in tools', () => {
|
|
21
|
+
test('file tools surface the basename', () => {
|
|
22
|
+
const input = JSON.stringify({ file_path: '/a/b/server.ts' })
|
|
23
|
+
expect(naturalAction('Edit', input)).toBe('edit: server.ts')
|
|
24
|
+
expect(naturalAction('Write', input)).toBe('write: server.ts')
|
|
25
|
+
expect(naturalAction('Read', input)).toBe('read: server.ts')
|
|
70
26
|
})
|
|
71
27
|
|
|
72
|
-
test('
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const input = JSON.stringify({
|
|
76
|
-
chat_id: '12345',
|
|
77
|
-
message_thread_id: '42',
|
|
78
|
-
topic: 'morning summary',
|
|
79
|
-
})
|
|
80
|
-
expect(summarizeToolForTitle('Skill', input)).toBe('Skill (topic: morning summary)')
|
|
28
|
+
test('file tools fall back to a generic phrase without a path', () => {
|
|
29
|
+
expect(naturalAction('Edit', undefined)).toBe('edit files')
|
|
30
|
+
expect(naturalAction('Write', JSON.stringify({ x: 1 }))).toBe('write files')
|
|
81
31
|
})
|
|
82
32
|
|
|
83
|
-
test('Bash
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
})
|
|
87
|
-
expect(summarizeToolForTitle('Bash', input)).toBe('Bash: echo hello world')
|
|
33
|
+
test('Bash surfaces a truncated command', () => {
|
|
34
|
+
const out = naturalAction('Bash', JSON.stringify({ command: 'ls /tmp' }))
|
|
35
|
+
expect(out).toBe('run: ls /tmp')
|
|
88
36
|
})
|
|
89
37
|
|
|
90
|
-
test('
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
38
|
+
test('Bash collapses whitespace and truncates long commands', () => {
|
|
39
|
+
const long = naturalAction(
|
|
40
|
+
'Bash',
|
|
41
|
+
JSON.stringify({ command: 'find /var/log -name "*.log" -mtime -1 -exec gzip {} \\;' }),
|
|
94
42
|
)
|
|
43
|
+
expect(long.startsWith('run: ')).toBe(true)
|
|
44
|
+
expect(long.endsWith('…')).toBe(true)
|
|
95
45
|
})
|
|
96
46
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// brackets even when Claude Code passes the input in a different
|
|
101
|
-
// shape.
|
|
102
|
-
test('Skill: falls back to skill_name field', () => {
|
|
103
|
-
const input = JSON.stringify({ skill_name: 'calendar' })
|
|
104
|
-
expect(summarizeToolForTitle('Skill', input)).toBe('Skill (calendar)')
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
test('Skill: falls back to skillName field', () => {
|
|
108
|
-
const input = JSON.stringify({ skillName: 'garmin' })
|
|
109
|
-
expect(summarizeToolForTitle('Skill', input)).toBe('Skill (garmin)')
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test('Skill: falls back to bare name field', () => {
|
|
113
|
-
const input = JSON.stringify({ name: 'home-assistant' })
|
|
114
|
-
expect(summarizeToolForTitle('Skill', input)).toBe('Skill (home-assistant)')
|
|
47
|
+
test('Skill names the skill', () => {
|
|
48
|
+
expect(naturalAction('Skill', JSON.stringify({ skill: 'mail' }))).toBe('use the mail skill')
|
|
49
|
+
expect(naturalAction('Skill', undefined)).toBe('use a skill')
|
|
115
50
|
})
|
|
116
51
|
|
|
117
|
-
test('
|
|
118
|
-
|
|
119
|
-
expect(
|
|
52
|
+
test('search / fetch tools surface their query or url', () => {
|
|
53
|
+
expect(naturalAction('Grep', JSON.stringify({ pattern: '**/*.ts' }))).toBe('search files for: **/*.ts')
|
|
54
|
+
expect(naturalAction('WebSearch', JSON.stringify({ query: 'tide times' }))).toBe('search the web for: tide times')
|
|
55
|
+
expect(naturalAction('WebFetch', JSON.stringify({ url: 'https://x.com' }))).toBe('fetch a web page: https://x.com')
|
|
120
56
|
})
|
|
121
57
|
|
|
122
|
-
test('
|
|
123
|
-
|
|
124
|
-
expect(
|
|
58
|
+
test('agent-ish tools read as plain phrases', () => {
|
|
59
|
+
expect(naturalAction('Task', undefined)).toBe('dispatch a sub-agent')
|
|
60
|
+
expect(naturalAction('TodoWrite', undefined)).toBe('update its task list')
|
|
61
|
+
expect(naturalAction('ExitPlanMode', undefined)).toBe('exit plan mode')
|
|
125
62
|
})
|
|
126
63
|
|
|
127
|
-
test('
|
|
128
|
-
|
|
129
|
-
expect(summarizeToolForTitle('Skill', input)).toBe('Skill (mail)')
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
test('MCP curated: agent-config tools render as human verb-phrases (#1215)', () => {
|
|
133
|
-
expect(summarizeToolForTitle('mcp__agent-config__skill_list', undefined)).toBe(
|
|
134
|
-
'List its own installed skills',
|
|
135
|
-
)
|
|
136
|
-
expect(summarizeToolForTitle('mcp__agent-config__cron_list', undefined)).toBe(
|
|
137
|
-
'List its own scheduled tasks',
|
|
138
|
-
)
|
|
139
|
-
expect(summarizeToolForTitle('mcp__agent-config__peers_list', undefined)).toBe(
|
|
140
|
-
'List the other agents on this instance',
|
|
141
|
-
)
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
test('MCP curated: hostd tools render as human verb-phrases (#1215)', () => {
|
|
145
|
-
expect(summarizeToolForTitle('mcp__hostd__agent_logs', undefined)).toBe(
|
|
146
|
-
"Read another agent's container logs",
|
|
147
|
-
)
|
|
148
|
-
expect(summarizeToolForTitle('mcp__hostd__agent_exec', undefined)).toBe(
|
|
149
|
-
'Run a read-only inspection inside another agent',
|
|
150
|
-
)
|
|
64
|
+
test('unknown tool falls back to "use <name>"', () => {
|
|
65
|
+
expect(naturalAction('SomeCustomTool', undefined)).toBe('use SomeCustomTool')
|
|
151
66
|
})
|
|
67
|
+
})
|
|
152
68
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
69
|
+
describe('naturalAction — MCP tools', () => {
|
|
70
|
+
test('curated internal tool reads as a bare verb-phrase', () => {
|
|
71
|
+
expect(naturalAction('mcp__agent-config__skill_list', undefined)).toBe(
|
|
72
|
+
'list its own installed skills',
|
|
156
73
|
)
|
|
157
74
|
})
|
|
158
75
|
|
|
159
|
-
test('
|
|
160
|
-
expect(
|
|
76
|
+
test('curated external tool gets a "(Server)" tag', () => {
|
|
77
|
+
expect(naturalAction('mcp__perplexity__search', undefined)).toBe('search the web (Perplexity)')
|
|
161
78
|
})
|
|
162
79
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
// uncurated MCP tools alike without an expand tap.
|
|
166
|
-
test('MCP curated tool appends first-arg hint when input_preview present (#1790)', () => {
|
|
167
|
-
const input = JSON.stringify({ key: 'coolify/api-token' })
|
|
168
|
-
expect(summarizeToolForTitle('mcp__agent-config__config_get', input)).toBe(
|
|
169
|
-
'Read its own merged config (key: coolify/api-token)',
|
|
170
|
-
)
|
|
80
|
+
test('uncurated internal tool de-snakes the verb', () => {
|
|
81
|
+
expect(naturalAction('mcp__hostd__do_thing', undefined)).toBe('do thing')
|
|
171
82
|
})
|
|
172
83
|
|
|
173
|
-
test('
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
'google-workspace: list files (folder_id: abc123)',
|
|
177
|
-
)
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
test('MCP arg hint skips routing-only keys (#1790)', () => {
|
|
181
|
-
const input = JSON.stringify({ chat_id: '12345', query: 'budget Q3' })
|
|
182
|
-
expect(summarizeToolForTitle('mcp__hindsight__recall', input)).toBe(
|
|
183
|
-
'Recall relevant memories (query: budget Q3)',
|
|
84
|
+
test('uncurated external tool de-snakes and tags the server', () => {
|
|
85
|
+
expect(naturalAction('mcp__google-workspace__list_files', undefined)).toBe(
|
|
86
|
+
'list files (Google Workspace)',
|
|
184
87
|
)
|
|
185
88
|
})
|
|
186
89
|
})
|
|
187
90
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
// for approval cards. Mirrors the vault_request_access card layout.
|
|
191
|
-
// ──────────────────────────────────────────────────────────────────────
|
|
192
|
-
|
|
193
|
-
describe('formatPermissionCardBody (#1790)', () => {
|
|
194
|
-
test('renders agent · summary, then a why-line, when both are present', () => {
|
|
91
|
+
describe('formatPermissionCardBody', () => {
|
|
92
|
+
test('renders "<Agent> wants to <action>" + why line', () => {
|
|
195
93
|
const body = formatPermissionCardBody({
|
|
196
|
-
toolName: '
|
|
197
|
-
inputPreview: JSON.stringify({
|
|
198
|
-
description: '
|
|
199
|
-
agentName: 'clerk',
|
|
200
|
-
})
|
|
201
|
-
expect(body).toBe(
|
|
202
|
-
[
|
|
203
|
-
'🔐 <b>clerk</b> · Skill (mail)',
|
|
204
|
-
'why: <i>Compose the morning brief</i>',
|
|
205
|
-
].join('\n'),
|
|
206
|
-
)
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
test('renders "why: <i>not provided</i>" when description is missing', () => {
|
|
210
|
-
const body = formatPermissionCardBody({
|
|
211
|
-
toolName: 'Bash',
|
|
212
|
-
inputPreview: JSON.stringify({ command: 'ls /tmp' }),
|
|
213
|
-
description: undefined,
|
|
94
|
+
toolName: 'Edit',
|
|
95
|
+
inputPreview: JSON.stringify({ file_path: '/work/supplement-log.md' }),
|
|
96
|
+
description: 'logging today\'s lifts',
|
|
214
97
|
agentName: 'gymbro',
|
|
215
98
|
})
|
|
216
99
|
expect(body).toBe(
|
|
217
|
-
['🔐 <b>
|
|
100
|
+
['🔐 <b>Gymbro</b> wants to edit: supplement-log.md', 'why: <i>logging today\'s lifts</i>'].join('\n'),
|
|
218
101
|
)
|
|
219
102
|
})
|
|
220
103
|
|
|
221
|
-
test('
|
|
104
|
+
test('shows "not provided" when description is missing or whitespace', () => {
|
|
222
105
|
const body = formatPermissionCardBody({
|
|
223
106
|
toolName: 'Bash',
|
|
224
107
|
inputPreview: JSON.stringify({ command: 'ls /tmp' }),
|
|
225
|
-
description: ' \n
|
|
108
|
+
description: ' \n ',
|
|
226
109
|
agentName: 'gymbro',
|
|
227
110
|
})
|
|
228
111
|
expect(body).toContain('why: <i>not provided</i>')
|
|
@@ -235,10 +118,10 @@ describe('formatPermissionCardBody (#1790)', () => {
|
|
|
235
118
|
description: 'do the thing',
|
|
236
119
|
agentName: null,
|
|
237
120
|
})
|
|
238
|
-
expect(body).toBe(['🔐
|
|
121
|
+
expect(body).toBe(['🔐 Use the mail skill', 'why: <i>do the thing</i>'].join('\n'))
|
|
239
122
|
})
|
|
240
123
|
|
|
241
|
-
test('HTML-escapes
|
|
124
|
+
test('HTML-escapes <, >, & in agentName / action / description', () => {
|
|
242
125
|
const body = formatPermissionCardBody({
|
|
243
126
|
toolName: 'Bash',
|
|
244
127
|
inputPreview: JSON.stringify({ command: 'echo "a < b && c > d"' }),
|
|
@@ -248,28 +131,22 @@ describe('formatPermissionCardBody (#1790)', () => {
|
|
|
248
131
|
expect(body).toContain('<test>')
|
|
249
132
|
expect(body).toContain('&')
|
|
250
133
|
expect(body).not.toContain('<test>')
|
|
251
|
-
// The literal "<i>not provided</i>" and "<b>...</b>" wrapping tags
|
|
252
|
-
// around legitimate fields must survive untouched — only the
|
|
253
|
-
// user-supplied content is escaped.
|
|
254
134
|
expect(body).toContain('<b>')
|
|
255
135
|
expect(body).toContain('<i>')
|
|
256
136
|
})
|
|
257
137
|
|
|
258
138
|
test('truncates a very long description with an ellipsis', () => {
|
|
259
|
-
const longWhy = 'x'.repeat(500)
|
|
260
139
|
const body = formatPermissionCardBody({
|
|
261
140
|
toolName: 'Skill',
|
|
262
141
|
inputPreview: JSON.stringify({ skill: 'mail' }),
|
|
263
|
-
description:
|
|
142
|
+
description: 'x'.repeat(500),
|
|
264
143
|
agentName: 'clerk',
|
|
265
144
|
})
|
|
266
|
-
// 240-char ceiling + trailing ellipsis
|
|
267
145
|
expect(body).toContain('xxxx…</i>')
|
|
268
|
-
|
|
269
|
-
expect(body.split('\n')[0]).toBe('🔐 <b>clerk</b> · Skill (mail)')
|
|
146
|
+
expect(body.split('\n')[0]).toBe('🔐 <b>Clerk</b> wants to use the mail skill')
|
|
270
147
|
})
|
|
271
148
|
|
|
272
|
-
test('collapses internal whitespace in
|
|
149
|
+
test('collapses internal whitespace in the description', () => {
|
|
273
150
|
const body = formatPermissionCardBody({
|
|
274
151
|
toolName: 'Skill',
|
|
275
152
|
inputPreview: JSON.stringify({ skill: 'mail' }),
|
|
@@ -279,3 +156,40 @@ describe('formatPermissionCardBody (#1790)', () => {
|
|
|
279
156
|
expect(body).toContain('why: <i>first second paragraph</i>')
|
|
280
157
|
})
|
|
281
158
|
})
|
|
159
|
+
|
|
160
|
+
describe('describeGrant — phrased from the chosen scope', () => {
|
|
161
|
+
test('MCP server wildcard → "use any <Server> tool"', () => {
|
|
162
|
+
expect(describeGrant('mcp__perplexity__search', undefined, opt('mcp__perplexity__*'))).toBe(
|
|
163
|
+
'use any Perplexity tool',
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('scoped file rule → "edit <basename>"', () => {
|
|
168
|
+
expect(describeGrant('Edit', undefined, opt('Edit(/work/supplement-log.md)'))).toBe(
|
|
169
|
+
'edit supplement-log.md',
|
|
170
|
+
)
|
|
171
|
+
expect(describeGrant('Read', undefined, opt('Read(/a/b/notes.md)'))).toBe('read notes.md')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('scoped Bash rule → "run <tok> commands"', () => {
|
|
175
|
+
expect(describeGrant('Bash', undefined, opt('Bash(npm:*)'))).toBe('run npm commands')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('scoped Skill rule → "use the <name> skill"', () => {
|
|
179
|
+
expect(describeGrant('Skill', undefined, opt('Skill(mail)'))).toBe('use the mail skill')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('bare category rules read as "any" grants', () => {
|
|
183
|
+
expect(describeGrant('Edit', undefined, opt('Edit'))).toBe('edit any file')
|
|
184
|
+
expect(describeGrant('Write', undefined, opt('Write'))).toBe('write any file')
|
|
185
|
+
expect(describeGrant('Read', undefined, opt('Read'))).toBe('read any file')
|
|
186
|
+
expect(describeGrant('Bash', undefined, opt('Bash'))).toBe('run any command')
|
|
187
|
+
expect(describeGrant('Skill', undefined, opt('Skill'))).toBe('use any skill')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('exact MCP tool grant falls back to the natural action', () => {
|
|
191
|
+
expect(describeGrant('mcp__perplexity__search', undefined, opt('mcp__perplexity__search'))).toBe(
|
|
192
|
+
'search the web (Perplexity)',
|
|
193
|
+
)
|
|
194
|
+
})
|
|
195
|
+
})
|