switchroom 0.12.0 → 0.12.1

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.
Files changed (29) hide show
  1. package/README.md +26 -11
  2. package/dist/auth-broker/index.js +1 -1
  3. package/dist/cli/skill-validate-pretool.mjs +7209 -0
  4. package/dist/cli/switchroom.js +869 -430
  5. package/dist/vault/broker/server.js +31 -22
  6. package/package.json +3 -2
  7. package/profiles/_shared/agent-self-service.md.hbs +1 -1
  8. package/skills/skill-creator/SKILL.md +52 -0
  9. package/telegram-plugin/auth-snapshot-format.ts +5 -5
  10. package/telegram-plugin/dist/gateway/gateway.js +62 -8
  11. package/telegram-plugin/gateway/access-validator.test.ts +8 -8
  12. package/telegram-plugin/gateway/access-validator.ts +1 -1
  13. package/telegram-plugin/gateway/boot-probes.ts +43 -3
  14. package/telegram-plugin/gateway/gateway.ts +72 -0
  15. package/telegram-plugin/recent-outbound-dedup.ts +1 -1
  16. package/telegram-plugin/registry/turns-schema.ts +1 -1
  17. package/telegram-plugin/tests/auth-add-flow.test.ts +1 -1
  18. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  19. package/telegram-plugin/tests/auth-snapshot-format.test.ts +17 -17
  20. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +10 -10
  21. package/telegram-plugin/tests/boot-probes.test.ts +37 -2
  22. package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +1 -1
  23. package/telegram-plugin/tests/fleet-state.test.ts +3 -2
  24. package/telegram-plugin/tests/secret-detect-audit.test.ts +1 -1
  25. package/telegram-plugin/tests/secret-detect-pipeline.test.ts +7 -6
  26. package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +6 -5
  27. package/telegram-plugin/tests/secret-detect.test.ts +8 -8
  28. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +8 -8
  29. package/telegram-plugin/tests/vault-request-access-tool.test.ts +51 -0
@@ -135,7 +135,7 @@ describe('renderAuthSnapshotFormat2', () => {
135
135
  // fixture. If the formatter changes shape, update these expectations.
136
136
  const fixtureSnaps: AccountSnapshot[] = [
137
137
  snap({
138
- label: 'ken.thompson@outlook.com.au',
138
+ label: 'alice@example.com',
139
139
  isActive: false,
140
140
  quota: quota({
141
141
  fiveHourUtilizationPct: 0,
@@ -146,7 +146,7 @@ describe('renderAuthSnapshotFormat2', () => {
146
146
  }),
147
147
  }),
148
148
  snap({
149
- label: 'me@kenthompson.com.au',
149
+ label: 'bob@example.com',
150
150
  isActive: false,
151
151
  quota: quota({
152
152
  fiveHourUtilizationPct: 0,
@@ -157,7 +157,7 @@ describe('renderAuthSnapshotFormat2', () => {
157
157
  }),
158
158
  }),
159
159
  snap({
160
- label: 'pixsoul@gmail.com',
160
+ label: 'you@example.com',
161
161
  isActive: true,
162
162
  quota: quota({
163
163
  fiveHourUtilizationPct: 8,
@@ -181,18 +181,18 @@ describe('renderAuthSnapshotFormat2', () => {
181
181
 
182
182
  it('marks the active account with ●', () => {
183
183
  const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
184
- expect(out).toMatch(/●\s*<code>pixsoul@gmail\.com<\/code>/);
184
+ expect(out).toMatch(/●\s*<code>you@example\.com<\/code>/);
185
185
  });
186
186
 
187
187
  it('shows "back …" for blocked accounts with binding-window word', () => {
188
188
  const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
189
- // me@kenthompson is blocked on 7d, recovers Sun
190
- expect(out).toMatch(/me@kenthompson\.com\.au[\s\S]*back .* 7-day cap/);
189
+ // bob@example is blocked on 7d, recovers Sun
190
+ expect(out).toMatch(/bob@example\.com[\s\S]*back .* 7-day cap/);
191
191
  });
192
192
 
193
193
  it('puts the imminent window first on healthy/throttling rows', () => {
194
194
  const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
195
- // pixsoul: 5h reset is in 7m, 7d reset is in 2d. 5h should come first.
195
+ // you: 5h reset is in 7m, 7d reset is in 2d. 5h should come first.
196
196
  const pixRow = out.split('\n').find((l) => l.includes('5h refills') && l.includes('7d resets'));
197
197
  expect(pixRow).toBeDefined();
198
198
  expect(pixRow!.indexOf('5h refills')).toBeLessThan(pixRow!.indexOf('7d resets'));
@@ -245,7 +245,7 @@ describe('renderFallbackAnnouncement', () => {
245
245
  representativeClaim: 'five_hour',
246
246
  });
247
247
 
248
- const PIXSOUL_HEALTHY = quota({
248
+ const YOU_HEALTHY = quota({
249
249
  fiveHourUtilizationPct: 8,
250
250
  sevenDayUtilizationPct: 20,
251
251
  fiveHourResetAt: new Date('2026-05-15T01:00:00Z'),
@@ -256,8 +256,8 @@ describe('renderFallbackAnnouncement', () => {
256
256
  const out5 = renderFallbackAnnouncement({
257
257
  oldLabel: 'ken@x',
258
258
  oldQuota: KEN_5H_BLOWN,
259
- newLabel: 'pixsoul@x',
260
- newQuota: PIXSOUL_HEALTHY,
259
+ newLabel: 'you@x',
260
+ newQuota: YOU_HEALTHY,
261
261
  triggerAgent: 'carrie',
262
262
  now: NOW,
263
263
  tz: 'UTC',
@@ -272,8 +272,8 @@ describe('renderFallbackAnnouncement', () => {
272
272
  sevenDayResetAt: new Date('2026-05-17T10:00:00Z'),
273
273
  representativeClaim: 'seven_day',
274
274
  }),
275
- newLabel: 'pixsoul@x',
276
- newQuota: PIXSOUL_HEALTHY,
275
+ newLabel: 'you@x',
276
+ newQuota: YOU_HEALTHY,
277
277
  triggerAgent: 'clerk',
278
278
  now: NOW,
279
279
  tz: 'UTC',
@@ -285,8 +285,8 @@ describe('renderFallbackAnnouncement', () => {
285
285
  const out = renderFallbackAnnouncement({
286
286
  oldLabel: 'ken@x',
287
287
  oldQuota: KEN_5H_BLOWN,
288
- newLabel: 'pixsoul@x',
289
- newQuota: PIXSOUL_HEALTHY,
288
+ newLabel: 'you@x',
289
+ newQuota: YOU_HEALTHY,
290
290
  triggerAgent: 'carrie',
291
291
  now: NOW,
292
292
  tz: 'UTC',
@@ -299,8 +299,8 @@ describe('renderFallbackAnnouncement', () => {
299
299
  const happy = renderFallbackAnnouncement({
300
300
  oldLabel: 'ken@x',
301
301
  oldQuota: KEN_5H_BLOWN,
302
- newLabel: 'pixsoul@x',
303
- newQuota: PIXSOUL_HEALTHY,
302
+ newLabel: 'you@x',
303
+ newQuota: YOU_HEALTHY,
304
304
  triggerAgent: 'carrie',
305
305
  now: NOW,
306
306
  tz: 'UTC',
@@ -310,7 +310,7 @@ describe('renderFallbackAnnouncement', () => {
310
310
  const tight = renderFallbackAnnouncement({
311
311
  oldLabel: 'ken@x',
312
312
  oldQuota: KEN_5H_BLOWN,
313
- newLabel: 'pixsoul@x',
313
+ newLabel: 'you@x',
314
314
  newQuota: quota({ fiveHourUtilizationPct: 85 }),
315
315
  triggerAgent: 'carrie',
316
316
  now: NOW,
@@ -44,7 +44,7 @@ describe('runFleetAutoFallback', () => {
44
44
  fanned: ['alice', 'bob'],
45
45
  }));
46
46
  const out = await runFleetAutoFallback({
47
- state: state('ken@x', ['ken@x', 'me@x', 'pixsoul@x']),
47
+ state: state('ken@x', ['ken@x', 'me@x', 'you@x']),
48
48
  quotas: [
49
49
  // ken: just blew 5h
50
50
  qOk({
@@ -58,7 +58,7 @@ describe('runFleetAutoFallback', () => {
58
58
  sevenDayResetAt: new Date('2026-05-17T10:00:00Z'),
59
59
  representativeClaim: 'seven_day',
60
60
  }),
61
- // pixsoul: healthy 5h/7d
61
+ // you: healthy 5h/7d
62
62
  qOk({ fiveHourUtilizationPct: 8, sevenDayUtilizationPct: 20 }),
63
63
  ],
64
64
  setActive,
@@ -69,10 +69,10 @@ describe('runFleetAutoFallback', () => {
69
69
 
70
70
  expect(out.kind).toBe('switched');
71
71
  expect(setActive).toHaveBeenCalledTimes(1);
72
- expect(setActive).toHaveBeenCalledWith('pixsoul@x');
72
+ expect(setActive).toHaveBeenCalledWith('you@x');
73
73
  if (out.kind === 'switched') {
74
74
  expect(out.oldLabel).toBe('ken@x');
75
- expect(out.newLabel).toBe('pixsoul@x');
75
+ expect(out.newLabel).toBe('you@x');
76
76
  expect(out.announcement).toContain('5-hour limit on ken@x');
77
77
  expect(out.announcement).toContain('Triggered by: agent <b>carrie</b>');
78
78
  expect(out.announcement).toContain('plenty of headroom');
@@ -112,7 +112,7 @@ describe('runFleetAutoFallback', () => {
112
112
  it('idempotency: skips swap when active probes healthy (stale event)', async () => {
113
113
  const setActive = vi.fn();
114
114
  const out = await runFleetAutoFallback({
115
- state: state('ken@x', ['ken@x', 'pixsoul@x']),
115
+ state: state('ken@x', ['ken@x', 'you@x']),
116
116
  quotas: [
117
117
  qOk({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 }),
118
118
  qOk({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 }),
@@ -147,14 +147,14 @@ describe('runFleetAutoFallback', () => {
147
147
  it('falls back to a throttling alternative when no healthy one exists', async () => {
148
148
  const setActive = vi.fn(async (label: string) => ({ active: label, fanned: [] }));
149
149
  const out = await runFleetAutoFallback({
150
- state: state('ken@x', ['ken@x', 'pixsoul@x']),
150
+ state: state('ken@x', ['ken@x', 'you@x']),
151
151
  quotas: [
152
152
  qOk({
153
153
  fiveHourUtilizationPct: 100,
154
154
  fiveHourResetAt: new Date('2026-05-15T05:50:00Z'),
155
155
  representativeClaim: 'five_hour',
156
156
  }),
157
- // pixsoul throttling at 85% but not blocked
157
+ // you throttling at 85% but not blocked
158
158
  qOk({ fiveHourUtilizationPct: 85, sevenDayUtilizationPct: 20 }),
159
159
  ],
160
160
  setActive,
@@ -164,7 +164,7 @@ describe('runFleetAutoFallback', () => {
164
164
  });
165
165
 
166
166
  expect(out.kind).toBe('switched');
167
- expect(setActive).toHaveBeenCalledWith('pixsoul@x');
167
+ expect(setActive).toHaveBeenCalledWith('you@x');
168
168
  if (out.kind === 'switched') {
169
169
  expect(out.announcement).toContain('near limit — watch this');
170
170
  }
@@ -173,7 +173,7 @@ describe('runFleetAutoFallback', () => {
173
173
  it('skips unknown-health (probe failed) when picking a target', async () => {
174
174
  const setActive = vi.fn(async (label: string) => ({ active: label, fanned: [] }));
175
175
  const out = await runFleetAutoFallback({
176
- state: state('ken@x', ['ken@x', 'broken@x', 'pixsoul@x']),
176
+ state: state('ken@x', ['ken@x', 'broken@x', 'you@x']),
177
177
  quotas: [
178
178
  qOk({ fiveHourUtilizationPct: 100, fiveHourResetAt: new Date('2026-05-15T05:50:00Z') }),
179
179
  { ok: false, reason: 'HTTP 401' },
@@ -186,7 +186,7 @@ describe('runFleetAutoFallback', () => {
186
186
  });
187
187
 
188
188
  expect(out.kind).toBe('switched');
189
- expect(setActive).toHaveBeenCalledWith('pixsoul@x');
189
+ expect(setActive).toHaveBeenCalledWith('you@x');
190
190
  });
191
191
  });
192
192
 
@@ -883,7 +883,7 @@ describe('probeSkills', () => {
883
883
  expect(result.detail).toContain('no skills dir')
884
884
  })
885
885
 
886
- it('returns ok with count when every skill resolves', async () => {
886
+ it('returns ok with every skill listed under Switchroom bucket when no overlay', async () => {
887
887
  const fs = makeSkillsFs(
888
888
  { [skillsDir]: ['simplify', 'review'] },
889
889
  new Set([
@@ -893,7 +893,40 @@ describe('probeSkills', () => {
893
893
  )
894
894
  const result = await probeSkills(agentDir, { fs })
895
895
  expect(result.status).toBe('ok')
896
- expect(result.detail).toContain('2 resolved')
896
+ expect(result.detail).toBe('Switchroom: review, simplify')
897
+ })
898
+
899
+ it('buckets overlay-installed skills under Agent', async () => {
900
+ const overlayDir = '/state/agent/skills.d'
901
+ const fs = makeSkillsFs(
902
+ {
903
+ [skillsDir]: ['simplify', 'review', 'webapp-testing'],
904
+ [overlayDir]: ['webapp-testing.yaml'],
905
+ },
906
+ new Set([
907
+ `${skillsDir}/simplify`, `${skillsDir}/simplify/SKILL.md`,
908
+ `${skillsDir}/review`, `${skillsDir}/review/SKILL.md`,
909
+ `${skillsDir}/webapp-testing`, `${skillsDir}/webapp-testing/SKILL.md`,
910
+ overlayDir,
911
+ ]),
912
+ )
913
+ const result = await probeSkills(agentDir, { fs })
914
+ expect(result.status).toBe('ok')
915
+ expect(result.detail).toBe('Switchroom: review, simplify · Agent: webapp-testing')
916
+ })
917
+
918
+ it('lists every skill without a +N more truncation in the healthy case', async () => {
919
+ const names = Array.from({ length: 12 }, (_, i) => `skill-${i.toString().padStart(2, '0')}`)
920
+ const fileSet = new Set<string>()
921
+ for (const n of names) {
922
+ fileSet.add(`${skillsDir}/${n}`)
923
+ fileSet.add(`${skillsDir}/${n}/SKILL.md`)
924
+ }
925
+ const fs = makeSkillsFs({ [skillsDir]: names }, fileSet)
926
+ const result = await probeSkills(agentDir, { fs })
927
+ expect(result.status).toBe('ok')
928
+ expect(result.detail).not.toContain('+')
929
+ for (const n of names) expect(result.detail).toContain(n)
897
930
  })
898
931
 
899
932
  it('degraded when at least one symlink dangles, names them up to cap', async () => {
@@ -912,6 +945,8 @@ describe('probeSkills', () => {
912
945
  expect(result.detail).toContain('also-gone')
913
946
  expect(result.detail).toContain('+1 more')
914
947
  expect(result.detail).not.toContain('and-this')
948
+ // resolved skills still surface alongside the dangling summary
949
+ expect(result.detail).toContain('Switchroom: review, simplify')
915
950
  })
916
951
 
917
952
  it('returns ok when entries dir is empty', async () => {
@@ -339,7 +339,7 @@
339
339
 
340
340
 
341
341
 
342
- 
342
+ 
343
343
  ❯  
344
344
  ────────────────────────────────────────────────────────────────────────────────
345
345
  ⏵⏵accepteditson(shift+tabtocycle)·esctointerrupt
@@ -155,8 +155,9 @@ describe('sanitiseToolArg', () => {
155
155
  expect(sanitiseToolArg('Write', { file_path: '/tmp/out.json' })).toBe('out.json')
156
156
  })
157
157
  it('redacts bearer-token-like strings in Bash commands', () => {
158
- const out = sanitiseToolArg('Bash', { command: 'curl -H "Authorization: Bearer sk-ant-1234567890abcdef" https://x' })
159
- expect(out).not.toContain('sk-ant-1234567890abcdef')
158
+ const tok = ['sk-ant-', '1234567890abcdef'].join('')
159
+ const out = sanitiseToolArg('Bash', { command: `curl -H "Authorization: Bearer ${tok}" https://x` })
160
+ expect(out).not.toContain(tok)
160
161
  expect(out.toLowerCase()).toContain('redacted')
161
162
  })
162
163
  it('returns empty string when no recognisable arg', () => {
@@ -12,7 +12,7 @@ describe('secret-detect audit log', () => {
12
12
  })
13
13
 
14
14
  it('emits a structured event with slug but never the raw value', () => {
15
- const raw = 'sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22'
15
+ const raw = ['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')
16
16
  emitAudit({
17
17
  chat_id: '-100',
18
18
  message_id: 5,
@@ -27,7 +27,8 @@ describe('pipeline.runPipeline', () => {
27
27
 
28
28
  it('stores a high-confidence hit and rewrites the prompt', () => {
29
29
  const { write, list, store } = mkFakeVault()
30
- const text = 'hey here is my key: sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22 thanks'
30
+ const tok = ['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')
31
+ const text = `hey here is my key: ${tok} thanks`
31
32
  const res = runPipeline({
32
33
  chat_id: '-100',
33
34
  message_id: 5,
@@ -38,9 +39,9 @@ describe('pipeline.runPipeline', () => {
38
39
  })
39
40
  expect(res.stored).toHaveLength(1)
40
41
  expect(res.rewritten_text).toContain('[secret stored as vault:')
41
- expect(res.rewritten_text).not.toContain('sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22')
42
+ expect(res.rewritten_text).not.toContain(tok)
42
43
  // The raw secret made it to the vault under the generated slug.
43
- expect([...store.values()]).toContain('sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22')
44
+ expect([...store.values()]).toContain(tok)
44
45
  // Audit emitted once with action=stored.
45
46
  const storedLogs = captured.filter((l) => l.includes('"action":"stored"'))
46
47
  expect(storedLogs).toHaveLength(1)
@@ -71,7 +72,7 @@ describe('pipeline.runPipeline', () => {
71
72
 
72
73
  it('treats suppressed high-confidence hits as ambiguous', () => {
73
74
  const { write, list, store } = mkFakeVault()
74
- const text = 'test sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22'
75
+ const text = `test ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
75
76
  const res = runPipeline({
76
77
  chat_id: 'c',
77
78
  message_id: 1,
@@ -89,7 +90,7 @@ describe('pipeline.runPipeline', () => {
89
90
  const { write, list, store } = mkFakeVault()
90
91
  store.set('anthropic_api_key_20260423', 'preexisting')
91
92
  const text =
92
- 'first: sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22 second: sk-ant-BqZ13yqRnPzx4MxK0TfAbY98Qw22'
93
+ `first: ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')} second: ${['sk-ant-', 'BqZ13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
93
94
  const res = runPipeline({
94
95
  chat_id: 'c',
95
96
  message_id: 2,
@@ -111,7 +112,7 @@ describe('pipeline.runPipeline', () => {
111
112
  const res = runPipeline({
112
113
  chat_id: 'c',
113
114
  message_id: 3,
114
- text: 'key is sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22',
115
+ text: `key is ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`,
115
116
  passphrase: 'pw',
116
117
  vaultWrite: failingWrite,
117
118
  vaultList: list,
@@ -20,12 +20,13 @@ import type { VaultWriteFn, VaultListFn } from '../secret-detect/vault-write.js'
20
20
  * this without breaking a test.
21
21
  */
22
22
  describe('suppressor: never silent-allows on structured matches', () => {
23
+ const tok = ['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')
23
24
  const phrasings = [
24
- 'this is a test, here is sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22',
25
- 'mock token: sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22',
26
- 'example: sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22',
27
- 'dummy sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22',
28
- 'fixture sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22',
25
+ `this is a test, here is ${tok}`,
26
+ `mock token: ${tok}`,
27
+ `example: ${tok}`,
28
+ `dummy ${tok}`,
29
+ `fixture ${tok}`,
29
30
  ]
30
31
 
31
32
  for (const text of phrasings) {
@@ -9,7 +9,7 @@ import { rewritePrompt } from '../secret-detect/rewrite.js'
9
9
 
10
10
  describe('mask.maskToken', () => {
11
11
  it('reveals first 6 + last 4 when length ≥ 18', () => {
12
- const tok = 'sk-ant-abc123XYZdefGHI456789'
12
+ const tok = ['sk-ant-', 'abc123XYZdefGHI456789'].join('')
13
13
  expect(maskToken(tok)).toBe(`${tok.slice(0, 6)}...${tok.slice(-4)}`)
14
14
  })
15
15
  it('returns *** for short inputs', () => {
@@ -96,7 +96,7 @@ describe('chunker.chunk', () => {
96
96
 
97
97
  describe('suppressor.isSuppressed', () => {
98
98
  it('demotes hits near test/mock/example/fixture/dummy', () => {
99
- const text = 'test: sk-ant-abc123defgh456789'
99
+ const text = `test: ${['sk-ant-', 'abc123defgh456789'].join('')}`
100
100
  const start = text.indexOf('sk-ant-')
101
101
  const end = text.length
102
102
  expect(isSuppressed(text, start, end)).toBe(true)
@@ -104,13 +104,13 @@ describe('suppressor.isSuppressed', () => {
104
104
  it('ignores markers more than 40 chars away', () => {
105
105
  // 80 chars of filler between "test" and the secret
106
106
  const filler = ' '.repeat(80)
107
- const text = `test${filler}sk-ant-abc123defgh456789`
107
+ const text = `test${filler}${['sk-ant-', 'abc123defgh456789'].join('')}`
108
108
  const start = text.indexOf('sk-ant-')
109
109
  const end = text.length
110
110
  expect(isSuppressed(text, start, end)).toBe(false)
111
111
  })
112
112
  it('whole-word only — "tested" does not trigger', () => {
113
- const text = 'untested sk-ant-abc123defgh456789'
113
+ const text = `untested ${['sk-ant-', 'abc123defgh456789'].join('')}`
114
114
  const start = text.indexOf('sk-ant-')
115
115
  const end = text.length
116
116
  expect(isSuppressed(text, start, end)).toBe(false)
@@ -150,7 +150,7 @@ describe('rewrite.rewritePrompt', () => {
150
150
  expect(out).toContain('[secret stored as vault:TOKEN]')
151
151
  })
152
152
  it('preserves non-secret substrings verbatim', () => {
153
- const text = 'please stash api key ANTHROPIC_API_KEY=sk-ant-ABCDEFGHIJKLMNOP now'
153
+ const text = `please stash api key ANTHROPIC_API_KEY=${['sk-ant-', 'ABCDEFGHIJKLMNOP'].join('')} now`
154
154
  const detections = detectSecrets(text)
155
155
  expect(detections.length).toBeGreaterThan(0)
156
156
  const targets = detections.map((d) => ({ detection: d, actual_slug: 'ANTHROPIC_API_KEY' }))
@@ -161,7 +161,7 @@ describe('rewrite.rewritePrompt', () => {
161
161
 
162
162
  describe('detectSecrets — end-to-end', () => {
163
163
  it('finds an anthropic key', () => {
164
- const text = 'here you go: sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22'
164
+ const text = `here you go: ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
165
165
  const d = detectSecrets(text)
166
166
  expect(d).toHaveLength(1)
167
167
  expect(d[0]!.rule_id).toBe('anthropic_api_key')
@@ -175,7 +175,7 @@ describe('detectSecrets — end-to-end', () => {
175
175
  expect(d.some((h) => h.rule_id === 'github_pat_classic')).toBe(true)
176
176
  })
177
177
  it('captures only the value for KEY=VALUE patterns', () => {
178
- const text = 'ANTHROPIC_API_KEY=sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22'
178
+ const text = `ANTHROPIC_API_KEY=${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
179
179
  const d = detectSecrets(text)
180
180
  const envHit = d.find((h) => h.rule_id === 'env_key_value' || h.rule_id === 'anthropic_api_key')
181
181
  expect(envHit).toBeDefined()
@@ -183,7 +183,7 @@ describe('detectSecrets — end-to-end', () => {
183
183
  expect(envHit!.matched_text.startsWith('sk-ant-')).toBe(true)
184
184
  })
185
185
  it('flags suppressed on nearby "test"', () => {
186
- const text = 'test token: sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22'
186
+ const text = `test token: ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
187
187
  const d = detectSecrets(text)
188
188
  expect(d.length).toBeGreaterThan(0)
189
189
  expect(d[0]!.suppressed).toBe(true)
@@ -25,7 +25,7 @@ const CTX_READ: VaultGrantInboundContext = {
25
25
  agent: 'gymbro',
26
26
  key: 'fatsecret/credentials',
27
27
  scope: 'read',
28
- chat_id: '8248703757',
28
+ chat_id: '12345',
29
29
  ttl_seconds: 30 * 86400,
30
30
  }
31
31
 
@@ -42,11 +42,11 @@ describe('buildVaultGrantApprovedInbound', () => {
42
42
  ctx: CTX_READ,
43
43
  grantId: 'vg_a1b2c3',
44
44
  stageId: 'stage-001',
45
- operatorId: '8248703757',
45
+ operatorId: '12345',
46
46
  nowMs: FIXED_NOW,
47
47
  })
48
48
  expect(msg.type).toBe('inbound')
49
- expect(msg.chatId).toBe('8248703757')
49
+ expect(msg.chatId).toBe('12345')
50
50
  expect(msg.user).toBe('vault-broker')
51
51
  expect(msg.userId).toBe(0)
52
52
  expect(msg.ts).toBe(FIXED_NOW)
@@ -71,7 +71,7 @@ describe('buildVaultGrantApprovedInbound', () => {
71
71
  ctx: CTX_READ,
72
72
  grantId: 'vg_a1b2c3',
73
73
  stageId: 'stage-001',
74
- operatorId: '8248703757',
74
+ operatorId: '12345',
75
75
  })
76
76
  expect(msg.meta).toEqual({
77
77
  source: 'vault_grant_approved',
@@ -80,7 +80,7 @@ describe('buildVaultGrantApprovedInbound', () => {
80
80
  scope: 'read',
81
81
  grant_id: 'vg_a1b2c3',
82
82
  stage_id: 'stage-001',
83
- operator_id: '8248703757',
83
+ operator_id: '12345',
84
84
  })
85
85
  })
86
86
 
@@ -151,7 +151,7 @@ describe('buildVaultGrantDeniedInbound', () => {
151
151
  const msg = buildVaultGrantDeniedInbound({
152
152
  ctx: CTX_READ,
153
153
  stageId: 'stage-001',
154
- operatorId: '8248703757',
154
+ operatorId: '12345',
155
155
  })
156
156
  expect(msg.meta).toEqual({
157
157
  source: 'vault_grant_denied',
@@ -159,7 +159,7 @@ describe('buildVaultGrantDeniedInbound', () => {
159
159
  key: 'fatsecret/credentials',
160
160
  scope: 'read',
161
161
  stage_id: 'stage-001',
162
- operator_id: '8248703757',
162
+ operator_id: '12345',
163
163
  })
164
164
  expect((msg.meta as { grant_id?: string }).grant_id).toBeUndefined()
165
165
  })
@@ -189,7 +189,7 @@ describe('buildVaultGrantDeniedInbound', () => {
189
189
  expect(denied.type).toBe('inbound')
190
190
  expect(denied.user).toBe('vault-broker')
191
191
  expect(denied.userId).toBe(0)
192
- expect(denied.chatId).toBe('8248703757')
192
+ expect(denied.chatId).toBe('12345')
193
193
  expect(denied.ts).toBe(FIXED_NOW)
194
194
  expect(denied.messageId).toBe(FIXED_NOW)
195
195
  })
@@ -112,3 +112,54 @@ describe('vault_request_access (#1012)', () => {
112
112
  expect(handlerBlock).toMatch(/allowFrom\.includes/)
113
113
  })
114
114
  })
115
+
116
+ /**
117
+ * Fix B (#1487 follow-up): vault_request_access must NOT card/mint when
118
+ * the agent's STANDING ACL already covers the key — and must decide
119
+ * that by probing the BROKER as the agent (no-token listViaBroker over
120
+ * the per-agent socket — path-as-identity), never a gateway-side
121
+ * config/checkAclByAgent read (the gateway can see newer config than
122
+ * the broker has loaded → "covered here, denied there"). Read scope
123
+ * only; fail-open on probe error. Source-pattern assertions matching
124
+ * this file's established style (the flow has Telegram + module-state
125
+ * side effects that aren't behaviourally unit-testable here).
126
+ */
127
+ describe('Fix B: vault_request_access standing-ACL-aware (#1487 follow-up)', () => {
128
+ const execBlock =
129
+ gatewaySrc.split('async function executeVaultRequestAccess')[1]?.split('\nasync function ')[0] ?? ''
130
+ const approveBlock =
131
+ gatewaySrc.split('async function performVaultAccessApproval')[1]?.split('\nasync function ')[0] ?? ''
132
+
133
+ it('request path: read-scope broker-probe short-circuits BEFORE the card is staged/sent', () => {
134
+ expect(execBlock).toContain('listViaBroker(')
135
+ expect(execBlock).toMatch(/scopeRaw === 'read'/)
136
+ const probeIdx = execBlock.indexOf('listViaBroker(')
137
+ const stageIdx = execBlock.indexOf('pendingVaultRequestAccesses.set(stageId')
138
+ expect(probeIdx).toBeGreaterThan(-1)
139
+ expect(stageIdx).toBeGreaterThan(-1)
140
+ expect(probeIdx).toBeLessThan(stageIdx)
141
+ expect(execBlock).toMatch(/ALREADY covered[\s\S]*?return\s*{/)
142
+ })
143
+
144
+ it('request path: decides via the BROKER, not a gateway-side config/ACL read (B2 — no config drift)', () => {
145
+ expect(execBlock).not.toContain('checkAclByAgent(')
146
+ expect(execBlock).not.toContain('loadSwitchroomConfig(')
147
+ })
148
+
149
+ it('operator-approve path: parallel guard short-circuits BEFORE mintGrantViaBroker', () => {
150
+ expect(approveBlock).toContain('listViaBroker(')
151
+ expect(approveBlock).toMatch(/pending\.scope === 'read'/)
152
+ const probeIdx = approveBlock.indexOf('listViaBroker(')
153
+ const mintIdx = approveBlock.indexOf('mintGrantViaBroker(mintArgs)')
154
+ expect(probeIdx).toBeGreaterThan(-1)
155
+ expect(mintIdx).toBeGreaterThan(-1)
156
+ expect(probeIdx).toBeLessThan(mintIdx)
157
+ expect(approveBlock).toMatch(/listViaBroker\([\s\S]*?pendingVaultRequestAccesses\.delete\(stageId\)[\s\S]*?return/)
158
+ expect(approveBlock).not.toContain('checkAclByAgent(')
159
+ })
160
+
161
+ it('both guards are fail-open (probe error → normal card/mint flow)', () => {
162
+ expect(execBlock).toMatch(/try\s*{[\s\S]*?listViaBroker\([\s\S]*?}\s*catch/)
163
+ expect(approveBlock).toMatch(/try\s*{[\s\S]*?listViaBroker\([\s\S]*?}\s*catch/)
164
+ })
165
+ })