switchroom 0.19.17 → 0.19.19
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/bin/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +13 -4
- package/dist/auth-broker/index.js +32 -5
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +13 -4
- package/dist/cli/switchroom.js +10614 -8104
- package/dist/host-control/main.js +12849 -11446
- package/dist/vault/approvals/kernel-server.js +90 -12
- package/dist/vault/broker/server.js +277 -94
- package/package.json +5 -3
- package/profiles/_base/start.sh.hbs +69 -5
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +3 -3
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +8 -6
- package/telegram-plugin/bridge/bridge.ts +25 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +28 -20
- package/telegram-plugin/dist/gateway/gateway.js +2077 -1087
- package/telegram-plugin/dist/server.js +32 -20
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +113 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
- package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
- package/telegram-plugin/gateway/ipc-server.ts +13 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/registry/turns-schema.test.ts +8 -3
- package/telegram-plugin/registry/turns-schema.ts +40 -12
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/silence-poke.ts +138 -0
- package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
- package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
- package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
- package/telegram-plugin/tests/registry-turns.test.ts +13 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
- package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
- package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/telegram-plugin/worker-activity-feed.ts +51 -1
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
- package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
- package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic-write + corruption contract for the gateway's four small JSON state
|
|
3
|
+
* stores: `pending-card-store`, `scoped-grant-store`, `missed-approvals-store`,
|
|
4
|
+
* `always-allow-persist-queue`.
|
|
5
|
+
*
|
|
6
|
+
* The bug these pin: each store did a non-atomic read-modify-write straight
|
|
7
|
+
* over the destination (`writeFileSync(dest, …)` = open + O_TRUNC + write),
|
|
8
|
+
* and wrapped the read in `catch { return [] }`. A crash between the truncate
|
|
9
|
+
* and the write left a TORN file; on the next boot the parse threw, the catch
|
|
10
|
+
* swallowed it, and every pending approval card / live scoped grant was
|
|
11
|
+
* silently forgotten. Nothing was logged. The operator just saw dead buttons.
|
|
12
|
+
*
|
|
13
|
+
* SCOPE OF THE CLAIM — deliberately narrow. What is asserted here is ATOMIC
|
|
14
|
+
* REPLACEMENT: a reader (or a crash) sees the whole old file or the whole new
|
|
15
|
+
* file, never a half-written one. That is NOT the same as crash-durability
|
|
16
|
+
* against power loss: `atomicWriteFileSync` fsyncs the tempfile but not the
|
|
17
|
+
* PARENT DIRECTORY, so an unjournalled dirent can still revert the rename
|
|
18
|
+
* after a power cut (tracked as a separate follow-up against
|
|
19
|
+
* `src/util/atomic.ts`, which is shared with vault entries and OAuth tokens
|
|
20
|
+
* and needs its own review). Whole-old-or-whole-new holds either way, and
|
|
21
|
+
* that is what the torn-file bug was about.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
25
|
+
import {
|
|
26
|
+
mkdtempSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
statSync,
|
|
33
|
+
utimesSync,
|
|
34
|
+
} from 'node:fs'
|
|
35
|
+
import { join } from 'node:path'
|
|
36
|
+
import { tmpdir } from 'node:os'
|
|
37
|
+
|
|
38
|
+
import { createPendingCardStore, type PersistedApprovalCard } from '../gateway/pending-card-store.js'
|
|
39
|
+
import { createScopedGrantStore } from '../gateway/scoped-grant-store.js'
|
|
40
|
+
import { createMissedApprovalsStore, type MissedApproval } from '../gateway/missed-approvals-store.js'
|
|
41
|
+
import {
|
|
42
|
+
atomicWriteSeam,
|
|
43
|
+
createAlwaysAllowPersistQueue,
|
|
44
|
+
} from '../gateway/always-allow-persist-queue.js'
|
|
45
|
+
import { MAX_QUARANTINED_COPIES, quarantineCorruptStoreFile } from '../gateway/store-file.js'
|
|
46
|
+
|
|
47
|
+
let dir: string
|
|
48
|
+
let logged: string[]
|
|
49
|
+
const log = (line: string) => {
|
|
50
|
+
logged.push(line)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
dir = mkdtempSync(join(tmpdir(), 'store-atomic-write-'))
|
|
55
|
+
logged = []
|
|
56
|
+
})
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
rmSync(dir, { recursive: true, force: true })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/** The bytes a crash between truncate and write leaves behind. */
|
|
62
|
+
const TORN = '[{"family":"vault_request_access","stageId":"abc'
|
|
63
|
+
|
|
64
|
+
function quarantinedFiles(base: string): string[] {
|
|
65
|
+
return readdirSync(dir).filter(f => f.startsWith(`${base}.corrupt-`))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sampleCard(stageId: string): PersistedApprovalCard {
|
|
69
|
+
return {
|
|
70
|
+
family: 'vault_request_access',
|
|
71
|
+
stageId,
|
|
72
|
+
agent: 'clerk',
|
|
73
|
+
chatId: '123',
|
|
74
|
+
stagedAt: 1_700_000_000_000,
|
|
75
|
+
key: 'coolify/api-token',
|
|
76
|
+
scope: 'read',
|
|
77
|
+
ttlSeconds: 900,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sampleMiss(requestId: string): MissedApproval {
|
|
82
|
+
return {
|
|
83
|
+
requestId,
|
|
84
|
+
toolName: 'mcp__brevo__post',
|
|
85
|
+
action: 'post to Brevo',
|
|
86
|
+
chatId: '123',
|
|
87
|
+
timedOutAt: 1_700_000_000_000,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('a torn file must NOT silently yield an empty store', () => {
|
|
92
|
+
it('pending-card-store: quarantines the torn bytes and logs loudly', () => {
|
|
93
|
+
writeFileSync(join(dir, 'pending-approval-cards.json'), TORN)
|
|
94
|
+
|
|
95
|
+
const store = createPendingCardStore(dir, log)
|
|
96
|
+
expect(store.loadAll()).toEqual([])
|
|
97
|
+
|
|
98
|
+
// The loss is OBSERVABLE: corrupt bytes preserved + a loud log line.
|
|
99
|
+
const quarantined = quarantinedFiles('pending-approval-cards.json')
|
|
100
|
+
expect(quarantined).toHaveLength(1)
|
|
101
|
+
expect(readFileSync(join(dir, quarantined[0]!), 'utf-8')).toBe(TORN)
|
|
102
|
+
expect(logged.join('')).toMatch(/pending-card-store CORRUPT/)
|
|
103
|
+
expect(logged.join('')).toMatch(/LOST/)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('scoped-grant-store: quarantines the torn bytes and logs loudly', () => {
|
|
107
|
+
writeFileSync(join(dir, 'scoped-grants.json'), TORN)
|
|
108
|
+
|
|
109
|
+
const store = createScopedGrantStore(dir, {}, log)
|
|
110
|
+
expect(store.load(Date.now()).size).toBe(0)
|
|
111
|
+
|
|
112
|
+
expect(quarantinedFiles('scoped-grants.json')).toHaveLength(1)
|
|
113
|
+
expect(logged.join('')).toMatch(/scoped-grant-store CORRUPT/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('missed-approvals-store: quarantines the torn bytes and logs loudly', () => {
|
|
117
|
+
writeFileSync(join(dir, 'missed-approvals.json'), '{"pending":[{"requestId":"r1"')
|
|
118
|
+
|
|
119
|
+
const store = createMissedApprovalsStore(dir, log)
|
|
120
|
+
expect(store.listPending()).toEqual([])
|
|
121
|
+
|
|
122
|
+
expect(quarantinedFiles('missed-approvals.json')).toHaveLength(1)
|
|
123
|
+
expect(logged.join('')).toMatch(/missed-approvals-store CORRUPT/)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('always-allow-persist-queue: quarantines the torn bytes and logs loudly', () => {
|
|
127
|
+
writeFileSync(join(dir, 'always-allow-persist-queue.json'), '{"entries":[{"id":"clerk::Ski')
|
|
128
|
+
|
|
129
|
+
const q = createAlwaysAllowPersistQueue(dir, undefined, log)
|
|
130
|
+
expect(q.listAll()).toEqual([])
|
|
131
|
+
|
|
132
|
+
expect(quarantinedFiles('always-allow-persist-queue.json')).toHaveLength(1)
|
|
133
|
+
expect(logged.join('')).toMatch(/always-allow-persist-queue CORRUPT/)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('a MISSING file is the normal cold start — no quarantine, no alarm', () => {
|
|
137
|
+
const store = createPendingCardStore(dir, log)
|
|
138
|
+
expect(store.loadAll()).toEqual([])
|
|
139
|
+
expect(readdirSync(dir)).toEqual([])
|
|
140
|
+
expect(logged).toEqual([])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('quarantine happens once — the next boot starts clean instead of re-alarming', () => {
|
|
144
|
+
writeFileSync(join(dir, 'pending-approval-cards.json'), TORN)
|
|
145
|
+
createPendingCardStore(dir, log).loadAll()
|
|
146
|
+
logged = []
|
|
147
|
+
|
|
148
|
+
createPendingCardStore(dir, log).loadAll()
|
|
149
|
+
expect(logged).toEqual([])
|
|
150
|
+
expect(quarantinedFiles('pending-approval-cards.json')).toHaveLength(1)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('valid JSON whose LIST FIELD is not a list is corruption, not "empty"', () => {
|
|
155
|
+
// The original silent-loss bug in a second costume: `{"pending": null}` and
|
|
156
|
+
// `{"entries": {}}` parse fine and reach an object at top level, so a naive
|
|
157
|
+
// `Array.isArray(x) ? x : []` coercion swallows them exactly the way the
|
|
158
|
+
// old `catch { return [] }` did — dead buttons, nothing logged.
|
|
159
|
+
|
|
160
|
+
it('missed-approvals-store: `pending` present but null quarantines', () => {
|
|
161
|
+
writeFileSync(join(dir, 'missed-approvals.json'), '{"pending":null,"delivered":[]}')
|
|
162
|
+
|
|
163
|
+
expect(createMissedApprovalsStore(dir, log).listPending()).toEqual([])
|
|
164
|
+
expect(quarantinedFiles('missed-approvals.json')).toHaveLength(1)
|
|
165
|
+
expect(logged.join('')).toMatch(/`pending` is present but not an array/)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('missed-approvals-store: `delivered` present but an object quarantines', () => {
|
|
169
|
+
writeFileSync(join(dir, 'missed-approvals.json'), '{"pending":[],"delivered":{}}')
|
|
170
|
+
|
|
171
|
+
expect(createMissedApprovalsStore(dir, log).getDigest('d1')).toBeUndefined()
|
|
172
|
+
expect(quarantinedFiles('missed-approvals.json')).toHaveLength(1)
|
|
173
|
+
expect(logged.join('')).toMatch(/`delivered` is present but not an array/)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('always-allow-persist-queue: `entries` present but an object quarantines', () => {
|
|
177
|
+
writeFileSync(join(dir, 'always-allow-persist-queue.json'), '{"entries":{}}')
|
|
178
|
+
|
|
179
|
+
expect(createAlwaysAllowPersistQueue(dir, undefined, log).listAll()).toEqual([])
|
|
180
|
+
expect(quarantinedFiles('always-allow-persist-queue.json')).toHaveLength(1)
|
|
181
|
+
expect(logged.join('')).toMatch(/`entries` is present but not an array/)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('an ABSENT list is the legitimate partial shape — silent, no quarantine', () => {
|
|
185
|
+
writeFileSync(join(dir, 'missed-approvals.json'), '{}')
|
|
186
|
+
writeFileSync(join(dir, 'always-allow-persist-queue.json'), '{}')
|
|
187
|
+
|
|
188
|
+
expect(createMissedApprovalsStore(dir, log).listPending()).toEqual([])
|
|
189
|
+
expect(createAlwaysAllowPersistQueue(dir, undefined, log).listAll()).toEqual([])
|
|
190
|
+
|
|
191
|
+
expect(quarantinedFiles('missed-approvals.json')).toEqual([])
|
|
192
|
+
expect(quarantinedFiles('always-allow-persist-queue.json')).toEqual([])
|
|
193
|
+
expect(logged).toEqual([])
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('an update replaces the destination by rename — it is never truncated in place', () => {
|
|
198
|
+
/**
|
|
199
|
+
* rename(2) swaps a NEW inode into the destination name, so a reader (or a
|
|
200
|
+
* crash) only ever sees the whole old file or the whole new file. An
|
|
201
|
+
* in-place `writeFileSync` keeps the destination inode and truncates it
|
|
202
|
+
* first — that truncate window is the torn-file bug. Inode identity is the
|
|
203
|
+
* observable difference.
|
|
204
|
+
*/
|
|
205
|
+
it('pending-card-store', () => {
|
|
206
|
+
const store = createPendingCardStore(dir, log)
|
|
207
|
+
store.add(sampleCard('s1'))
|
|
208
|
+
const file = join(dir, 'pending-approval-cards.json')
|
|
209
|
+
const first = statSync(file).ino
|
|
210
|
+
|
|
211
|
+
store.add(sampleCard('s2'))
|
|
212
|
+
expect(statSync(file).ino).not.toBe(first)
|
|
213
|
+
expect(store.loadAll().map(c => c.stageId)).toEqual(['s1', 's2'])
|
|
214
|
+
// No tempfile left behind on a successful write.
|
|
215
|
+
expect(readdirSync(dir)).toEqual(['pending-approval-cards.json'])
|
|
216
|
+
// Perms stay owner-only even across a replace.
|
|
217
|
+
expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('scoped-grant-store', () => {
|
|
221
|
+
const store = createScopedGrantStore(dir, {}, log)
|
|
222
|
+
store.save(new Map())
|
|
223
|
+
const file = join(dir, 'scoped-grants.json')
|
|
224
|
+
const first = statSync(file).ino
|
|
225
|
+
|
|
226
|
+
store.save(new Map())
|
|
227
|
+
expect(statSync(file).ino).not.toBe(first)
|
|
228
|
+
expect(readdirSync(dir)).toEqual(['scoped-grants.json'])
|
|
229
|
+
expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('missed-approvals-store', () => {
|
|
233
|
+
const store = createMissedApprovalsStore(dir, log)
|
|
234
|
+
store.add(sampleMiss('r1'))
|
|
235
|
+
const file = join(dir, 'missed-approvals.json')
|
|
236
|
+
const first = statSync(file).ino
|
|
237
|
+
|
|
238
|
+
store.add(sampleMiss('r2'))
|
|
239
|
+
expect(statSync(file).ino).not.toBe(first)
|
|
240
|
+
expect(store.listPending().map(e => e.requestId)).toEqual(['r1', 'r2'])
|
|
241
|
+
expect(readdirSync(dir)).toEqual(['missed-approvals.json'])
|
|
242
|
+
expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('always-allow-persist-queue', async () => {
|
|
246
|
+
const q = createAlwaysAllowPersistQueue(dir, undefined, log)
|
|
247
|
+
await q.enqueue({ agentName: 'clerk', rule: 'Skill(calendar)', grantPhrase: 'use the calendar skill' })
|
|
248
|
+
const file = join(dir, 'always-allow-persist-queue.json')
|
|
249
|
+
const first = statSync(file).ino
|
|
250
|
+
|
|
251
|
+
await q.enqueue({ agentName: 'clerk', rule: 'Skill(drive)', grantPhrase: 'use drive' })
|
|
252
|
+
expect(statSync(file).ino).not.toBe(first)
|
|
253
|
+
expect(q.listAll()).toHaveLength(2)
|
|
254
|
+
expect(readdirSync(dir)).toEqual(['always-allow-persist-queue.json'])
|
|
255
|
+
expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
describe('a write failure is surfaced and leaves the previous good content intact', () => {
|
|
260
|
+
// NOTE ON WHAT THIS PROVES: the good write below goes through the REAL
|
|
261
|
+
// atomic writer (`atomicWriteSeam`); only the second call is diverted to a
|
|
262
|
+
// thrower. So this asserts failure PROPAGATION plus "the destination still
|
|
263
|
+
// holds the last good snapshot" — it does NOT by itself prove atomicity
|
|
264
|
+
// (the inode suite above does that).
|
|
265
|
+
it('always-allow-persist-queue: the entry queued before the failure is still readable', async () => {
|
|
266
|
+
let fail = false
|
|
267
|
+
const seam = ((...args: Parameters<typeof atomicWriteSeam>) => {
|
|
268
|
+
if (fail) throw new Error('ENOSPC: no space left on device')
|
|
269
|
+
return atomicWriteSeam(...args)
|
|
270
|
+
}) as typeof atomicWriteSeam
|
|
271
|
+
|
|
272
|
+
const q = createAlwaysAllowPersistQueue(dir, seam, log)
|
|
273
|
+
await q.enqueue({ agentName: 'clerk', rule: 'Skill(calendar)', grantPhrase: 'use the calendar skill' })
|
|
274
|
+
const file = join(dir, 'always-allow-persist-queue.json')
|
|
275
|
+
const before = readFileSync(file, 'utf-8')
|
|
276
|
+
const beforeIno = statSync(file).ino
|
|
277
|
+
|
|
278
|
+
fail = true
|
|
279
|
+
await expect(
|
|
280
|
+
q.enqueue({ agentName: 'clerk', rule: 'Skill(drive)', grantPhrase: 'use drive' }),
|
|
281
|
+
).rejects.toThrow(/ENOSPC/)
|
|
282
|
+
|
|
283
|
+
// Byte-identical AND the same inode — the failed write never even touched
|
|
284
|
+
// the destination, let alone truncated it.
|
|
285
|
+
expect(readFileSync(file, 'utf-8')).toBe(before)
|
|
286
|
+
expect(statSync(file).ino).toBe(beforeIno)
|
|
287
|
+
fail = false
|
|
288
|
+
expect(q.listAll().map(e => e.rule)).toEqual(['Skill(calendar)'])
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
describe('an UNREADABLE file (not ENOENT) is loud, is never quarantined on read, and fails closed on write', () => {
|
|
293
|
+
// A directory at the store path makes `readFileSync` fail EISDIR — a
|
|
294
|
+
// deterministic stand-in for the flaky-mount EIO / transient EACCES class
|
|
295
|
+
// (chmod tricks don't work: CI and the containers run as root).
|
|
296
|
+
it('pending-card-store: logs loudly and does NOT quarantine on the read', () => {
|
|
297
|
+
mkdirSync(join(dir, 'pending-approval-cards.json'))
|
|
298
|
+
|
|
299
|
+
expect(createPendingCardStore(dir, log).loadAll()).toEqual([])
|
|
300
|
+
|
|
301
|
+
expect(logged.join('')).toMatch(/pending-card-store read FAILED/)
|
|
302
|
+
expect(logged.join('')).toMatch(/EISDIR/)
|
|
303
|
+
// Crucially NOT quarantined — a file we merely failed to read may still
|
|
304
|
+
// hold perfectly good state, and a transient fault must not destroy it.
|
|
305
|
+
expect(quarantinedFiles('pending-approval-cards.json')).toEqual([])
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('pending-card-store: the next write PRESERVES the unreadable file instead of clobbering it', () => {
|
|
309
|
+
mkdirSync(join(dir, 'pending-approval-cards.json'))
|
|
310
|
+
const store = createPendingCardStore(dir, log)
|
|
311
|
+
|
|
312
|
+
store.add(sampleCard('s1'))
|
|
313
|
+
|
|
314
|
+
// The bytes we could not read were moved aside, not overwritten...
|
|
315
|
+
expect(quarantinedFiles('pending-approval-cards.json')).toHaveLength(1)
|
|
316
|
+
expect(logged.join('')).toMatch(/Preserved the previous \(unreadable\) bytes/)
|
|
317
|
+
// ...and the store carried on with a fresh, valid file.
|
|
318
|
+
expect(store.loadAll().map(c => c.stageId)).toEqual(['s1'])
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('always-allow-persist-queue: the preserve step runs before the write', async () => {
|
|
322
|
+
mkdirSync(join(dir, 'always-allow-persist-queue.json'))
|
|
323
|
+
const q = createAlwaysAllowPersistQueue(dir, undefined, log)
|
|
324
|
+
|
|
325
|
+
await q.enqueue({ agentName: 'clerk', rule: 'Skill(calendar)', grantPhrase: 'use the calendar skill' })
|
|
326
|
+
|
|
327
|
+
expect(quarantinedFiles('always-allow-persist-queue.json')).toHaveLength(1)
|
|
328
|
+
expect(q.listAll().map(e => e.rule)).toEqual(['Skill(calendar)'])
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
describe('kill switch: a disabled scoped-grant store never touches the file', () => {
|
|
333
|
+
it('does not read, quarantine, or write when SWITCHROOM_SCOPED_GRANT_PERSIST=0', () => {
|
|
334
|
+
writeFileSync(join(dir, 'scoped-grants.json'), TORN)
|
|
335
|
+
const store = createScopedGrantStore(dir, { SWITCHROOM_SCOPED_GRANT_PERSIST: '0' }, log)
|
|
336
|
+
|
|
337
|
+
expect(store.enabled).toBe(false)
|
|
338
|
+
expect(store.load(Date.now()).size).toBe(0)
|
|
339
|
+
store.save(new Map())
|
|
340
|
+
|
|
341
|
+
// The pre-existing (corrupt) file is left exactly as found — a disabled
|
|
342
|
+
// store must not be the thing that moves the operator's data around.
|
|
343
|
+
expect(readFileSync(join(dir, 'scoped-grants.json'), 'utf-8')).toBe(TORN)
|
|
344
|
+
expect(quarantinedFiles('scoped-grants.json')).toEqual([])
|
|
345
|
+
expect(logged).toEqual([])
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
describe('quarantine copies do not collide and do not accumulate without bound', () => {
|
|
350
|
+
it('two quarantines in the same millisecond both survive (random suffix)', () => {
|
|
351
|
+
const file = join(dir, 'x.json')
|
|
352
|
+
for (const bytes of ['first', 'second']) {
|
|
353
|
+
writeFileSync(file, bytes)
|
|
354
|
+
quarantineCorruptStoreFile(file, 'test-store', 'forced', log)
|
|
355
|
+
}
|
|
356
|
+
const copies = quarantinedFiles('x.json')
|
|
357
|
+
expect(copies).toHaveLength(2)
|
|
358
|
+
expect(copies.map(f => readFileSync(join(dir, f), 'utf-8')).sort()).toEqual(['first', 'second'])
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it(`keeps at most ${MAX_QUARANTINED_COPIES} forensic copies, newest wins`, () => {
|
|
362
|
+
const file = join(dir, 'x.json')
|
|
363
|
+
const total = MAX_QUARANTINED_COPIES + 4
|
|
364
|
+
for (let i = 0; i < total; i++) {
|
|
365
|
+
writeFileSync(file, `copy-${i}`)
|
|
366
|
+
quarantineCorruptStoreFile(file, 'test-store', 'forced', log)
|
|
367
|
+
}
|
|
368
|
+
const copies = quarantinedFiles('x.json')
|
|
369
|
+
expect(copies).toHaveLength(MAX_QUARANTINED_COPIES)
|
|
370
|
+
// The reap drops the OLDEST — exactly the newest MAX are kept, even
|
|
371
|
+
// though these all land in the same millisecond (so the ordering cannot
|
|
372
|
+
// be coming from the embedded timestamp alone).
|
|
373
|
+
expect(copies.map(f => readFileSync(join(dir, f), 'utf-8')).sort()).toEqual(
|
|
374
|
+
Array.from({ length: MAX_QUARANTINED_COPIES }, (_, i) => `copy-${total - MAX_QUARANTINED_COPIES + i}`),
|
|
375
|
+
)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('a RESTARTED process does not get its fresh copy reaped as "oldest"', () => {
|
|
379
|
+
// Regression: ordering used to come from the `<epoch-ms>-<seq>` embedded
|
|
380
|
+
// in the filename, but `seq` restarts at 0 on every process boot. A prior
|
|
381
|
+
// process that burned seq 000001..000005 within millisecond T, followed
|
|
382
|
+
// by a restart quarantining in that same T, produced a NEW copy named
|
|
383
|
+
// `T-000000-…` — lexicographically the OLDEST of the set, so the reaper
|
|
384
|
+
// deleted precisely the forensic bytes the operator needed. Ordering is
|
|
385
|
+
// by mtime now, which no process restart can rewind.
|
|
386
|
+
const file = join(dir, 'x.json')
|
|
387
|
+
const stamp = Date.now()
|
|
388
|
+
for (let i = 1; i <= MAX_QUARANTINED_COPIES; i++) {
|
|
389
|
+
// The prior process's sequence numbers — deliberately HIGHER than the
|
|
390
|
+
// restarted process's (whose counter is back near 0), which is the
|
|
391
|
+
// whole point: a name-ordered reaper would rank the fresh copy oldest.
|
|
392
|
+
const seeded = `${file}.corrupt-${stamp}-${String(900_000 + i).padStart(6, '0')}-aaaaaaaa`
|
|
393
|
+
writeFileSync(seeded, `old-${i}`)
|
|
394
|
+
// Genuinely older on disk (the prior process wrote them seconds ago),
|
|
395
|
+
// while still carrying the same embedded millisecond in the NAME — so
|
|
396
|
+
// a name-ordered reaper and an mtime-ordered one disagree, which is
|
|
397
|
+
// exactly the discriminating case.
|
|
398
|
+
const past = new Date(Date.now() - (MAX_QUARANTINED_COPIES + 1 - i) * 1000)
|
|
399
|
+
utimesSync(seeded, past, past)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// The restarted process quarantines fresh bytes; its seq is back at 0.
|
|
403
|
+
writeFileSync(file, 'NEWEST-FORENSIC-BYTES')
|
|
404
|
+
quarantineCorruptStoreFile(file, 'test-store', 'forced', log)
|
|
405
|
+
|
|
406
|
+
const kept = quarantinedFiles('x.json').map(f => readFileSync(join(dir, f), 'utf-8'))
|
|
407
|
+
expect(kept).toHaveLength(MAX_QUARANTINED_COPIES)
|
|
408
|
+
expect(kept).toContain('NEWEST-FORENSIC-BYTES')
|
|
409
|
+
expect(kept).not.toContain('old-1') // the genuinely oldest went instead
|
|
410
|
+
})
|
|
411
|
+
})
|
|
@@ -32,6 +32,12 @@ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
|
32
32
|
import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
33
33
|
import { BackstopDeliveryLedger } from '../gateway/backstop-delivery.js'
|
|
34
34
|
import { redact } from '../secret-detect/redact.js'
|
|
35
|
+
import { createTurnTypingLoop } from '../gateway/turn-typing-loop.js'
|
|
36
|
+
import {
|
|
37
|
+
createTypingEmitter,
|
|
38
|
+
TYPING_FLOOR_MS,
|
|
39
|
+
TYPING_REFRESH_MS,
|
|
40
|
+
} from '../typing-emitter.js'
|
|
35
41
|
import type { CurrentTurn } from '../gateway/gateway.js'
|
|
36
42
|
|
|
37
43
|
const CHAT = '1001'
|
|
@@ -190,7 +196,8 @@ function makeStreamDeps(opts?: {
|
|
|
190
196
|
handlePtyPartial: noop,
|
|
191
197
|
isDmChatId: () => true,
|
|
192
198
|
isLegitimatelyWorking: () => false,
|
|
193
|
-
|
|
199
|
+
// `teardown` is exercised when a SECOND enqueue supersedes a prior turn.
|
|
200
|
+
makeNarrativeGate: () => ({ show: noop, stage: noop, resolveOnTool: noop, flushAtTurnEnd: noop, teardown: noop }),
|
|
194
201
|
promoteQueuedStatus: noop,
|
|
195
202
|
purgeReactionTracking: noop,
|
|
196
203
|
redactOutboundText: (t: string) => redact(t),
|
|
@@ -547,6 +554,18 @@ describe('structural — the singleton lives once in gateway, never in the modul
|
|
|
547
554
|
expect(body).toContain('handleSessionEventCore(gatewayStreamRenderDeps(), ev)')
|
|
548
555
|
})
|
|
549
556
|
|
|
557
|
+
it('#3544 — the handback seam\'s hasLiveTurn wiring verifies the live turn OWNS the key', () => {
|
|
558
|
+
// Under the emission-authority flag OFF (default) `currentTurnMap.get(key)`
|
|
559
|
+
// returns the most-recent singleton regardless of key. Without the
|
|
560
|
+
// statusKey ownership check, a live turn on ANOTHER chat would suppress the
|
|
561
|
+
// orphan reap's stop and leak a typing interval forever.
|
|
562
|
+
const after = gatewaySrc.split('hasLiveTurn: (key) => {')[1] ?? ''
|
|
563
|
+
const body = after.split('\n },')[0] ?? after
|
|
564
|
+
expect(body).toContain('currentTurnMap.get(key)')
|
|
565
|
+
expect(body).toContain('live.endedAt != null')
|
|
566
|
+
expect(body).toMatch(/statusKey\(live\.sessionChatId, live\.sessionThreadId\) === key/)
|
|
567
|
+
})
|
|
568
|
+
|
|
550
569
|
it('the module never reads the currentTurn global — only getCurrentTurn() (Amendment 9)', () => {
|
|
551
570
|
const fnBody = streamSrc.split('export function handleSessionEvent(')[1] ?? ''
|
|
552
571
|
// No bare `currentTurn` value reads survive outside comments; the live-read
|
|
@@ -559,3 +578,86 @@ describe('structural — the singleton lives once in gateway, never in the modul
|
|
|
559
578
|
expect(codeOnly).toMatch(/getCurrentTurn\(\)/)
|
|
560
579
|
})
|
|
561
580
|
})
|
|
581
|
+
|
|
582
|
+
// ── #3544: every minted turn is lit, not just an ADOPTING handback turn ─────
|
|
583
|
+
// The defect: the turn-long `typing…` loop was armed only inside
|
|
584
|
+
// `if (handbackAdoption != null)`. A handback whose pre-turn entry was deduped
|
|
585
|
+
// (parallel workers on one topic), had no derivable turn id, or was already
|
|
586
|
+
// reaped adopts NOTHING — and, being a synthetic turn, never went through the
|
|
587
|
+
// real-inbound arm in turn-start-surfaces either. Result: zero typing for the
|
|
588
|
+
// whole compose window. These drive the REAL `handleSessionEvent` enqueue seam
|
|
589
|
+
// against a REAL `createTurnTypingLoop` over the REAL `createTypingEmitter`, so
|
|
590
|
+
// the assertion is the OUTCOME the user sees — chat actions on the wire — not
|
|
591
|
+
// the call.
|
|
592
|
+
describe('#3544 — turn-start typing is unconditional at the enqueue seam', () => {
|
|
593
|
+
interface TypingRig {
|
|
594
|
+
h: StreamHarness
|
|
595
|
+
sent: Array<{ chatId: string; threadId: number | null }>
|
|
596
|
+
emitter: ReturnType<typeof createTypingEmitter>
|
|
597
|
+
loop: ReturnType<typeof createTurnTypingLoop>
|
|
598
|
+
clock: { t: number }
|
|
599
|
+
}
|
|
600
|
+
function makeTypingRig(opts?: { adopts?: boolean }): TypingRig {
|
|
601
|
+
const h = makeStreamDeps({ turn: null })
|
|
602
|
+
const sent: Array<{ chatId: string; threadId: number | null }> = []
|
|
603
|
+
const clock = { t: 1_000_000 }
|
|
604
|
+
const tKey = (c: string, t: number | null) => `${c}:${t ?? '_'}`
|
|
605
|
+
// The REAL shared floor — the 2026-07-11 flood-ban guard.
|
|
606
|
+
const emitter = createTypingEmitter({
|
|
607
|
+
chatKey: tKey,
|
|
608
|
+
now: () => clock.t,
|
|
609
|
+
send: (chatId, threadId) => { sent.push({ chatId, threadId }) },
|
|
610
|
+
})
|
|
611
|
+
const loop = createTurnTypingLoop({
|
|
612
|
+
sendChatAction: (c, t) => { emitter.emit(c, t, 'typing') },
|
|
613
|
+
chatKey: tKey,
|
|
614
|
+
refreshMs: TYPING_REFRESH_MS,
|
|
615
|
+
})
|
|
616
|
+
const d = h.deps as unknown as Record<string, unknown>
|
|
617
|
+
d.HANDBACK_PRETURN_ENABLED = true
|
|
618
|
+
// Adoption MISSES — the deduped-parallel-worker case from #3544.
|
|
619
|
+
d.handbackPreturnSignal = { tryAdopt: () => (opts?.adopts === true ? { statusKey: `${CHAT}:main`, chatId: CHAT, threadId: null, activityMessageId: null, startedAt: clock.t, pinned: false } : null) }
|
|
620
|
+
d.startTurnTypingLoop = (c: string, t: number | null) => loop.start(c, t)
|
|
621
|
+
return { h, sent, emitter, loop, clock }
|
|
622
|
+
}
|
|
623
|
+
const enqueue = (chatId = CHAT) => ({
|
|
624
|
+
kind: 'enqueue' as const,
|
|
625
|
+
chatId,
|
|
626
|
+
messageId: null,
|
|
627
|
+
threadId: null,
|
|
628
|
+
rawContent: '🤝 A background worker you dispatched has finished.',
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
it('a handback turn whose pre-turn entry was DEDUPED (adoption misses) still emits typing', () => {
|
|
632
|
+
const rig = makeTypingRig({ adopts: false })
|
|
633
|
+
handleSessionEvent(rig.h.deps, enqueue())
|
|
634
|
+
// Pre-fix this was []: no adoption ⇒ no loop ⇒ a dark compose window.
|
|
635
|
+
expect(rig.sent).toEqual([{ chatId: CHAT, threadId: null }])
|
|
636
|
+
expect(rig.loop.activeCount()).toBe(1)
|
|
637
|
+
rig.loop.stopAll()
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it('an ADOPTING handback turn is unchanged — still exactly one arm, one action', () => {
|
|
641
|
+
const rig = makeTypingRig({ adopts: true })
|
|
642
|
+
handleSessionEvent(rig.h.deps, enqueue())
|
|
643
|
+
expect(rig.sent).toHaveLength(1)
|
|
644
|
+
expect(rig.loop.activeCount()).toBe(1)
|
|
645
|
+
rig.loop.stopAll()
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
it('FLOOD GUARD: N turns arming on ONE chat inside the floor cost at most ONE chat action', () => {
|
|
649
|
+
const rig = makeTypingRig({ adopts: false })
|
|
650
|
+
// 12 arms on one chat inside a single floor window: a real-inbound arm
|
|
651
|
+
// (turn-start-surfaces) plus repeated enqueue seams / restarts.
|
|
652
|
+
rig.loop.start(CHAT, null) // stand-in for the real-inbound path's arm
|
|
653
|
+
for (let i = 0; i < 11; i++) handleSessionEvent(rig.h.deps, enqueue())
|
|
654
|
+
expect(rig.sent).toHaveLength(1) // the floor coalesced all 12
|
|
655
|
+
expect(rig.loop.activeCount()).toBe(1) // restart-safe: no interval pile-up
|
|
656
|
+
// Crossing the floor lets exactly one more through, then the floor holds again.
|
|
657
|
+
rig.clock.t += TYPING_FLOOR_MS
|
|
658
|
+
for (let i = 0; i < 5; i++) handleSessionEvent(rig.h.deps, enqueue())
|
|
659
|
+
expect(rig.sent).toHaveLength(2)
|
|
660
|
+
rig.loop.stopAll()
|
|
661
|
+
rig.emitter.reset()
|
|
662
|
+
})
|
|
663
|
+
})
|
|
@@ -21,8 +21,15 @@ describe("describeToolUse — friendly per-tool rendering (draft-mirror)", () =>
|
|
|
21
21
|
expect(
|
|
22
22
|
describeToolUse("Bash", { command: "ls -la /tmp", description: "List workspace" }),
|
|
23
23
|
).toBe("List workspace");
|
|
24
|
-
// No description →
|
|
25
|
-
|
|
24
|
+
// No description → sanitised PROGRAM-ONLY derivation, never the raw
|
|
25
|
+
// command (its args routinely carry tokens and private paths). The old
|
|
26
|
+
// behavior was a constant "Running a command" for every Bash call, which
|
|
27
|
+
// froze the sub-agent step feed on one line for a whole job.
|
|
28
|
+
expect(describeToolUse("Bash", { command: "grep -r foo ." })).toBe("Running grep");
|
|
29
|
+
// Nothing safe to derive → the generic label is still the floor.
|
|
30
|
+
expect(describeToolUse("Bash", { command: "$(cat /run/secrets/token) --x" })).toBe(
|
|
31
|
+
"Running a command",
|
|
32
|
+
);
|
|
26
33
|
});
|
|
27
34
|
|
|
28
35
|
it("Read/Edit/Write render the file basename, not the full path", () => {
|
|
@@ -68,3 +68,97 @@ describe('computeLabel — surface-tool suppression is key-agnostic', () => {
|
|
|
68
68
|
expect(computeLabel('mcp__clerk-telegram__get_recent_messages', {})).toBe('Reading chat history')
|
|
69
69
|
})
|
|
70
70
|
})
|
|
71
|
+
|
|
72
|
+
// ─── Bash label without a model-authored description ─────────────────────
|
|
73
|
+
//
|
|
74
|
+
// `description` is OPTIONAL, so a sub-agent that never writes one used to
|
|
75
|
+
// produce the constant "Running a command" for every Bash call — the worker
|
|
76
|
+
// feed then deduped every repeat away and the card froze on one line for the
|
|
77
|
+
// whole job. The fallback derives a label from the command, but ONLY through
|
|
78
|
+
// an allowlist: program basename, plus one bare subcommand for known
|
|
79
|
+
// multiplexers. Command lines carry tokens, passwords, credential URLs and
|
|
80
|
+
// private paths, and this string is rendered into a Telegram message.
|
|
81
|
+
describe('computeLabel — Bash fallback when description is omitted', () => {
|
|
82
|
+
it('prefers the model-authored description when present', () => {
|
|
83
|
+
expect(computeLabel('Bash', { command: 'git push origin main', description: 'Push branch' }))
|
|
84
|
+
.toBe('Push branch')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('derives the program name when description is missing', () => {
|
|
88
|
+
expect(computeLabel('Bash', { command: 'grep -r foo .' })).toBe('Running grep')
|
|
89
|
+
expect(computeLabel('Bash', { command: 'ls -la /var/log' })).toBe('Running ls')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('adds a bare subcommand for known multiplexers', () => {
|
|
93
|
+
expect(computeLabel('Bash', { command: 'git status --short' })).toBe('Running git status')
|
|
94
|
+
expect(computeLabel('Bash', { command: 'docker ps -a' })).toBe('Running docker ps')
|
|
95
|
+
expect(computeLabel('Bash', { command: 'npm run lint' })).toBe('Running npm run')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('produces DISTINCT labels for distinct commands (the frozen-card fix)', () => {
|
|
99
|
+
const labels = [
|
|
100
|
+
'git status', 'npm test', 'ls /tmp', 'grep -n x y', 'docker logs c',
|
|
101
|
+
].map((c) => computeLabel('Bash', { command: c }))
|
|
102
|
+
expect(new Set(labels).size).toBe(labels.length)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('skips navigation prefixes and wrappers to the real program', () => {
|
|
106
|
+
expect(computeLabel('Bash', { command: 'cd /srv/app && git pull' })).toBe('Running git pull')
|
|
107
|
+
expect(computeLabel('Bash', { command: 'sudo systemctl restart nginx' }))
|
|
108
|
+
.toBe('Running systemctl restart')
|
|
109
|
+
expect(computeLabel('Bash', { command: 'FOO=bar deploy.sh' })).toBe('Running deploy.sh')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('NEVER echoes arguments, flag values, URLs, or env assignments', () => {
|
|
113
|
+
// Token-shaped literals are assembled at runtime: GitHub Push Protection
|
|
114
|
+
// and check-no-pii-secrets both reject contiguous ones, fake or not
|
|
115
|
+
// (pattern from secret-detect-secretlint.test.ts).
|
|
116
|
+
const fakeAnthropic = 'sk-' + 'ant-oat01-FAKEVALUE'
|
|
117
|
+
const cases = [
|
|
118
|
+
'curl -H "Authorization: Bearer ' + 'sk-' + 'live-abcdef123456" https://api.example.com/v1/x',
|
|
119
|
+
'psql postgres://user:hunter2@db.internal:5432/prod -c "select 1"',
|
|
120
|
+
'aws s3 cp s3://private-bucket/key.pem . --profile prod',
|
|
121
|
+
'export TOKEN=ghp_AAAABBBBCCCCDDDD && ./run',
|
|
122
|
+
`echo "${fakeAnthropic}" > /root/.creds`,
|
|
123
|
+
'ssh deploy@10.0.0.7 -i /root/.ssh/id_ed25519',
|
|
124
|
+
'git clone https://x-access-token:ghs_SECRET@github.com/o/r.git',
|
|
125
|
+
]
|
|
126
|
+
const forbidden = /sk-|ghp_|ghs_|hunter2|Bearer|SECRET|10\.0\.0\.7|@|:\/\/|=/
|
|
127
|
+
for (const command of cases) {
|
|
128
|
+
const label = computeLabel('Bash', { command })
|
|
129
|
+
expect(label).not.toBeNull()
|
|
130
|
+
expect(label).not.toMatch(forbidden)
|
|
131
|
+
// Whatever it says, it must be short and made of the allowlisted shape.
|
|
132
|
+
expect(label).toMatch(/^Running (a command|[A-Za-z][A-Za-z0-9._+-]{0,19}( [a-z][a-z-]{1,15})?)$/)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('never inspects a heredoc / multiline body', () => {
|
|
137
|
+
const label = computeLabel('Bash', {
|
|
138
|
+
command: 'cat <<EOF > /tmp/x\nAPI_KEY=sk-live-shouldnotleak\nEOF',
|
|
139
|
+
})
|
|
140
|
+
expect(label).toBe('Running cat')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('falls back to the generic label when nothing safe can be derived', () => {
|
|
144
|
+
expect(computeLabel('Bash', { command: '$(cat /run/secrets/token) --x' })).toBe('Running a command')
|
|
145
|
+
expect(computeLabel('Bash', {})).toBe('Running a command')
|
|
146
|
+
expect(computeLabel('Bash', { command: ' ' })).toBe('Running a command')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('computeLabel — Bash subcommand is never a flag value', () => {
|
|
151
|
+
it('only accepts the token IMMEDIATELY after a multiplexer', () => {
|
|
152
|
+
// A flag value (a private context/host/profile name) must never surface.
|
|
153
|
+
expect(computeLabel('Bash', { command: 'docker --context prod-internal ps' }))
|
|
154
|
+
.toBe('Running docker')
|
|
155
|
+
expect(computeLabel('Bash', { command: 'git -C /srv/private-app status' }))
|
|
156
|
+
.toBe('Running git')
|
|
157
|
+
expect(computeLabel('Bash', { command: 'aws --profile customer-acme s3 ls' }))
|
|
158
|
+
.toBe('Running aws')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('still reads the plain subcommand form', () => {
|
|
162
|
+
expect(computeLabel('Bash', { command: 'git commit -m "wip"' })).toBe('Running git commit')
|
|
163
|
+
})
|
|
164
|
+
})
|