@agfpd/iapeer 0.2.77 → 0.2.78
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/LICENSE +202 -0
- package/README.md +2 -2
- package/package.json +21 -2
- package/src/cli/index.ts +47 -48
- package/src/connect/index.ts +4 -4
- package/src/core/constants.ts +3 -3
- package/src/core/normalize.ts +2 -2
- package/src/create/index.ts +1 -1
- package/src/daemon/index.ts +5 -5
- package/src/daemon/main.ts +9 -9
- package/src/enable/index.ts +19 -18
- package/src/enable/provisionCommand.ts +10 -10
- package/src/identity/index.ts +2 -2
- package/src/identity/profileStandard.ts +8 -8
- package/src/init/index.ts +10 -10
- package/src/install/index.ts +2 -2
- package/src/install/signing.ts +3 -4
- package/src/launch/adapters/claude.ts +7 -7
- package/src/launch/adapters/codex.ts +5 -6
- package/src/launch/canary.ts +8 -8
- package/src/launch/cmdlog.ts +3 -3
- package/src/launch/composeSystemPrompt.ts +2 -1
- package/src/launch/index.ts +5 -6
- package/src/launch/invocation.ts +1 -1
- package/src/launch/launchd.ts +14 -15
- package/src/launch/launchdRun.ts +6 -6
- package/src/launch/nativeMemory.ts +9 -9
- package/src/launch/ptyHost.ts +1 -1
- package/src/launch/readyGateModel.ts +3 -3
- package/src/launch/types.ts +7 -7
- package/src/lifecycle/eventlog.ts +2 -2
- package/src/lifecycle/index.ts +21 -21
- package/src/lifecycle/queue.ts +1 -1
- package/src/onboard/index.ts +19 -13
- package/src/onboard/memory.ts +5 -5
- package/src/onboard/steps.ts +8 -9
- package/src/provision/index.ts +2 -3
- package/src/runtime/deploy.ts +2 -2
- package/src/runtime/index.ts +4 -4
- package/src/shadow/gate.ts +6 -6
- package/src/shadow/index.ts +2 -2
- package/src/status/index.ts +11 -11
- package/src/storage/index.ts +1 -1
- package/src/supervisor/daemon.ts +14 -8
- package/src/transport/index.ts +11 -11
- package/src/update/index.ts +4 -4
- package/src/cli/cli.test.ts +0 -603
- package/src/cli/listTui.test.ts +0 -71
- package/src/codec/codec.test.ts +0 -271
- package/src/connect/connect.test.ts +0 -163
- package/src/core/constants.test.ts +0 -32
- package/src/core/normalize.test.ts +0 -98
- package/src/create/create.test.ts +0 -143
- package/src/daemon/daemon-http.test.ts +0 -114
- package/src/daemon/daemon.test.ts +0 -267
- package/src/daemon/main.test.ts +0 -413
- package/src/enable/enable.test.ts +0 -95
- package/src/enable/provisionCommand.test.ts +0 -107
- package/src/identity/identity.test.ts +0 -279
- package/src/identity/profileStandard.test.ts +0 -204
- package/src/init/init.test.ts +0 -215
- package/src/init/runtime-resolve.test.ts +0 -49
- package/src/install/install.test.ts +0 -66
- package/src/install/signing.test.ts +0 -84
- package/src/launch/bootdeliver.test.ts +0 -108
- package/src/launch/bootstrap.test.ts +0 -56
- package/src/launch/canary.test.ts +0 -346
- package/src/launch/cmdlog.test.ts +0 -94
- package/src/launch/codexHooksTrust.test.ts +0 -248
- package/src/launch/composeSystemPrompt.layers.test.ts +0 -445
- package/src/launch/composeSystemPrompt.test.ts +0 -145
- package/src/launch/hostComposerOccupied.test.ts +0 -63
- package/src/launch/invocation.test.ts +0 -174
- package/src/launch/launch.test.ts +0 -359
- package/src/launch/launchd.test.ts +0 -499
- package/src/launch/nativeMemory.test.ts +0 -193
- package/src/launch/ptyHost.test.ts +0 -38
- package/src/launch/readyGateModel.test.ts +0 -72
- package/src/launch/sockdir.test.ts +0 -74
- package/src/lifecycle/eventlog.test.ts +0 -114
- package/src/lifecycle/lifecycle.test.ts +0 -1056
- package/src/lifecycle/queue.test.ts +0 -185
- package/src/onboard/memory.test.ts +0 -157
- package/src/onboard/onboard.test.ts +0 -39
- package/src/onboard/steps.test.ts +0 -201
- package/src/provision/provision.test.ts +0 -288
- package/src/registry/registry.test.ts +0 -432
- package/src/runtime/runtime.test.ts +0 -226
- package/src/runtime/update.test.ts +0 -192
- package/src/shadow/enumerate-skip.test.ts +0 -67
- package/src/shadow/gate.test.ts +0 -286
- package/src/shadow/install.test.ts +0 -153
- package/src/shadow/shadow.test.ts +0 -42
- package/src/status/status.test.ts +0 -208
- package/src/storage/peers-home.test.ts +0 -34
- package/src/storage/rotatelog.test.ts +0 -44
- package/src/storage/storage.test.ts +0 -65
- package/src/supervisor/attached-marker.test.ts +0 -84
- package/src/supervisor/boot.test.ts +0 -66
- package/src/supervisor/client.attach.test.ts +0 -107
- package/src/supervisor/daemon.test.ts +0 -213
- package/src/supervisor/deliver.test.ts +0 -58
- package/src/supervisor/panelog.test.ts +0 -95
- package/src/supervisor/protocol.test.ts +0 -222
- package/src/supervisor/selfargv.test.ts +0 -22
- package/src/supervisor/spinner-strip.test.ts +0 -52
- package/src/transport/compactDone.test.ts +0 -144
- package/src/transport/composerQueue.test.ts +0 -204
- package/src/transport/hostControl.test.ts +0 -87
- package/src/transport/hostDeliver.test.ts +0 -137
- package/src/transport/hostLive.test.ts +0 -50
- package/src/transport/routesend-wake.test.ts +0 -357
- package/src/update/update.test.ts +0 -208
|
@@ -1,346 +0,0 @@
|
|
|
1
|
-
// Server-death canary — pure script-builder shape + live-tmux behavior:
|
|
2
|
-
// dirty server death (SIGKILL) → ev=server-exit line + forensics snapshot;
|
|
3
|
-
// clean teardown (signal / killSession) → silent, no record. Live tests follow
|
|
4
|
-
// the suite's test.if(tmuxAvailable) sandbox-socket pattern (lifecycle.test.ts).
|
|
5
|
-
|
|
6
|
-
import { afterAll, describe, expect, test } from 'bun:test'
|
|
7
|
-
import { spawnSync } from 'child_process'
|
|
8
|
-
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'fs'
|
|
9
|
-
import { tmpdir } from 'os'
|
|
10
|
-
import { join } from 'path'
|
|
11
|
-
import {
|
|
12
|
-
canaryChannel,
|
|
13
|
-
canaryProcessPattern,
|
|
14
|
-
canaryScript,
|
|
15
|
-
dismissCanary,
|
|
16
|
-
ensureServerCanary,
|
|
17
|
-
exitLogPath,
|
|
18
|
-
serverDeathsDir,
|
|
19
|
-
signalCanaryClean,
|
|
20
|
-
} from './canary.ts'
|
|
21
|
-
import { killSession } from '../lifecycle/index.ts'
|
|
22
|
-
import { teardownAlwaysOnSession } from './launchdRun.ts'
|
|
23
|
-
|
|
24
|
-
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
25
|
-
|
|
26
|
-
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
|
27
|
-
|
|
28
|
-
/** Poll until `cond` is true or `ms` elapsed. */
|
|
29
|
-
async function waitFor(cond: () => boolean, ms: number): Promise<boolean> {
|
|
30
|
-
const deadline = Date.now() + ms
|
|
31
|
-
while (Date.now() < deadline) {
|
|
32
|
-
if (cond()) return true
|
|
33
|
-
await sleep(100)
|
|
34
|
-
}
|
|
35
|
-
return cond()
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function canaryRunning(identity: string): boolean {
|
|
39
|
-
return (
|
|
40
|
-
spawnSync('pgrep', ['-f', `wait-for.*${canaryChannel(identity)}([^a-z0-9-]|$)`], { stdio: 'ignore' }).status === 0
|
|
41
|
-
)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function serverPid(sock: string): number {
|
|
45
|
-
const r = spawnSync('tmux', ['-S', sock, 'display-message', '-p', '#{pid}'], { encoding: 'utf8' })
|
|
46
|
-
return Number((r.stdout ?? '').trim())
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
describe('canary script (pure)', () => {
|
|
50
|
-
test('channel is per-identity', () => {
|
|
51
|
-
expect(canaryChannel('claude-bob')).toBe('iap-canary-claude-bob')
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
test('exitLogPath → exits.log inside the dir', () => {
|
|
55
|
-
expect(exitLogPath('/r/logs/iapeer')).toBe('/r/logs/iapeer/exits.log')
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test('script carries the v2 protocol: wait-for channel, deliberate-silence guards, liveness probe, record line, forensics', () => {
|
|
59
|
-
const s = canaryScript({
|
|
60
|
-
identity: 'claude-bob',
|
|
61
|
-
sock: '/tmp/x.sock',
|
|
62
|
-
tmuxBin: '/opt/homebrew/bin/tmux',
|
|
63
|
-
exitLogFile: '/r/logs/iapeer/exits.log',
|
|
64
|
-
forensicsDir: '/r/logs/iapeer/server-deaths',
|
|
65
|
-
})
|
|
66
|
-
expect(s).toContain(`wait-for 'iap-canary-claude-bob'`) // the blocking client
|
|
67
|
-
expect(s).toContain(`trap 'exit 0' HUP INT TERM`) // dismissed sh = silent (the ONLY deliberate silencer)
|
|
68
|
-
// v2: NO exit code is trusted as deliberate (a TERMed client returns rc=0 —
|
|
69
|
-
// proven live); the SERVER's liveness decides, after a dismissal grace sleep.
|
|
70
|
-
expect(s).not.toContain(`[ "$rc" -eq 0 ] || [ "$rc" -ge 128 ]; then exit 0`) // the v1 hole
|
|
71
|
-
expect(s).toContain('sleep 2') // dismissal grace window
|
|
72
|
-
expect(s).toContain(`kill -0 "$SPID" 2>/dev/null; then exit 0`) // original server alive → nothing to record
|
|
73
|
-
expect(s).toContain(`[ -z "$SPID" ]`) // fallback only when the original pid was unavailable
|
|
74
|
-
expect(s).toContain('cause=server-vanished') // connection drop (SIGKILL/OOM class)
|
|
75
|
-
expect(s).toContain('cause=signaled-server-gone') // rc=0: channel/client-TERM, server died
|
|
76
|
-
expect(s).toContain('cause=client-killed-server-gone') // rc≥128: client took a hard kill
|
|
77
|
-
expect(s).toContain('ev=server-exit identity=claude-bob') // the exits.log record
|
|
78
|
-
expect(s).toContain(`>> '/r/logs/iapeer/exits.log'`)
|
|
79
|
-
expect(s).toContain('/r/logs/iapeer/server-deaths/claude-bob') // forensics file
|
|
80
|
-
expect(s).toContain('vm_stat') // memory evidence (OOM hypothesis)
|
|
81
|
-
expect(s).toContain(`'/opt/homebrew/bin/tmux' -S '/tmp/x.sock'`) // abs tmux, quoted
|
|
82
|
-
expect(s).not.toContain('cmdlog-tail') // no cmdLogDir → no cmdlog capture line
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
test('with cmdLogDir → forensics tail the dead server cmdlog (client-COMMAND kill attribution)', () => {
|
|
86
|
-
const s = canaryScript({
|
|
87
|
-
identity: 'claude-bob',
|
|
88
|
-
sock: '/tmp/x.sock',
|
|
89
|
-
tmuxBin: '/opt/homebrew/bin/tmux',
|
|
90
|
-
exitLogFile: '/r/logs/iapeer/exits.log',
|
|
91
|
-
forensicsDir: '/r/logs/iapeer/server-deaths',
|
|
92
|
-
cmdLogDir: '/r/logs/iapeer/tmux-cmdlog/claude-bob',
|
|
93
|
-
})
|
|
94
|
-
expect(s).toContain('cmdlog-tail') // the kill-server/kill-session witness
|
|
95
|
-
// tails the dead server's own -v log by its captured pid, greps the command lines
|
|
96
|
-
expect(s).toContain(`'/r/logs/iapeer/tmux-cmdlog/claude-bob/tmux-server-'"$SPID"'.log'`)
|
|
97
|
-
expect(s).toContain(`grep -aE 'command:'`)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
test('ensureServerCanary without exitLogDir → skipped (observability off)', () => {
|
|
101
|
-
expect(ensureServerCanary({ identity: 'claude-none', sock: '/tmp/none.sock' })).toBe('skipped')
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
test('canaryProcessPattern is identity-anchored (no prefix bleed) and matches both canary processes', () => {
|
|
105
|
-
const p = canaryProcessPattern('claude-iap')
|
|
106
|
-
expect(p).toBe('iap-canary-claude-iap([^a-z0-9-]|$)')
|
|
107
|
-
const re = new RegExp(p)
|
|
108
|
-
expect(re.test(`/opt/homebrew/bin/tmux -S /tmp/x.sock wait-for iap-canary-claude-iap`)).toBe(true) // client argv
|
|
109
|
-
expect(re.test(`/bin/sh -c ... wait-for 'iap-canary-claude-iap'\n...`)).toBe(true) // sh -c script (quoted channel)
|
|
110
|
-
expect(re.test(`tmux wait-for iap-canary-claude-iap-memory`)).toBe(false) // prefix identity must NOT match
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
test('dismissCanary is a harmless no-op when no canary runs', () => {
|
|
114
|
-
expect(() => dismissCanary('claude-nobody-here')).not.toThrow()
|
|
115
|
-
})
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
|
|
119
|
-
const dir = mkdtempSync(join(tmpdir(), 'iap-canary-'))
|
|
120
|
-
const socks: string[] = []
|
|
121
|
-
|
|
122
|
-
function bringUp(identity: string): { sock: string; logDir: string } {
|
|
123
|
-
const sock = join(dir, `${identity}.sock`)
|
|
124
|
-
socks.push(sock)
|
|
125
|
-
const r = spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', identity, 'sleep', '120'], {
|
|
126
|
-
stdio: 'ignore',
|
|
127
|
-
})
|
|
128
|
-
expect(r.status).toBe(0)
|
|
129
|
-
return { sock, logDir: join(dir, `logs-${identity}`) }
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const allIds = [
|
|
133
|
-
'claude-canadirty',
|
|
134
|
-
'claude-canaclean',
|
|
135
|
-
'claude-canakill',
|
|
136
|
-
'notifier-canatear',
|
|
137
|
-
'claude-canasweep',
|
|
138
|
-
'claude-canaclient',
|
|
139
|
-
'claude-canareplaced',
|
|
140
|
-
]
|
|
141
|
-
|
|
142
|
-
afterAll(() => {
|
|
143
|
-
for (const sock of socks) {
|
|
144
|
-
// teardown is DELIBERATE → silence each canary (signal + dismiss) before
|
|
145
|
-
// killing its server — the v2 contract for every deliberate path.
|
|
146
|
-
for (const id of allIds) {
|
|
147
|
-
signalCanaryClean(sock, id)
|
|
148
|
-
dismissCanary(id)
|
|
149
|
-
}
|
|
150
|
-
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
151
|
-
}
|
|
152
|
-
rmSync(dir, { recursive: true, force: true })
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
test(
|
|
156
|
-
'dirty server death (SIGKILL) → ev=server-exit + forensics snapshot',
|
|
157
|
-
async () => {
|
|
158
|
-
const identity = 'claude-canadirty'
|
|
159
|
-
const { sock, logDir } = bringUp(identity)
|
|
160
|
-
const pid = serverPid(sock)
|
|
161
|
-
expect(pid).toBeGreaterThan(0)
|
|
162
|
-
|
|
163
|
-
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
|
|
164
|
-
// idempotency: a second ensure must NOT double-spawn
|
|
165
|
-
expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
|
|
166
|
-
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('already')
|
|
167
|
-
await sleep(500) // let the wait-for client register on the channel
|
|
168
|
-
|
|
169
|
-
process.kill(pid, 'SIGKILL') // the silent server-killer class
|
|
170
|
-
const log = exitLogPath(logDir)
|
|
171
|
-
expect(await waitFor(() => existsSync(log) && readFileSync(log, 'utf8').includes('ev=server-exit'), 8000)).toBe(
|
|
172
|
-
true,
|
|
173
|
-
)
|
|
174
|
-
const line = readFileSync(log, 'utf8')
|
|
175
|
-
expect(line).toContain(`ev=server-exit identity=${identity}`)
|
|
176
|
-
expect(line).toContain(`server_pid=${pid}`)
|
|
177
|
-
const snaps = readdirSync(serverDeathsDir(logDir))
|
|
178
|
-
expect(snaps.length).toBe(1)
|
|
179
|
-
const snap = readFileSync(join(serverDeathsDir(logDir), snaps[0]!), 'utf8')
|
|
180
|
-
expect(snap).toContain(`server-death identity=${identity}`)
|
|
181
|
-
// canary is one-shot: after firing it must be gone
|
|
182
|
-
expect(await waitFor(() => !canaryRunning(identity), 3000)).toBe(true)
|
|
183
|
-
},
|
|
184
|
-
20000,
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
test(
|
|
188
|
-
'clean teardown (signal, then kill-server) → silent, no record',
|
|
189
|
-
async () => {
|
|
190
|
-
const identity = 'claude-canaclean'
|
|
191
|
-
const { sock, logDir } = bringUp(identity)
|
|
192
|
-
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
|
|
193
|
-
expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
|
|
194
|
-
await sleep(500) // let the wait-for client register before signaling
|
|
195
|
-
|
|
196
|
-
signalCanaryClean(sock, identity)
|
|
197
|
-
expect(await waitFor(() => !canaryRunning(identity), 3000)).toBe(true) // exited silently
|
|
198
|
-
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
199
|
-
await sleep(300)
|
|
200
|
-
expect(existsSync(exitLogPath(logDir)) && readFileSync(exitLogPath(logDir), 'utf8').includes('ev=server-exit')).toBe(
|
|
201
|
-
false,
|
|
202
|
-
)
|
|
203
|
-
},
|
|
204
|
-
20000,
|
|
205
|
-
)
|
|
206
|
-
|
|
207
|
-
test(
|
|
208
|
-
'teardownAlwaysOnSession (signal-exit of runAlwaysOn) kills session+server, canary stays silent',
|
|
209
|
-
async () => {
|
|
210
|
-
const identity = 'notifier-canatear'
|
|
211
|
-
const { sock, logDir } = bringUp(identity)
|
|
212
|
-
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
|
|
213
|
-
expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
|
|
214
|
-
await sleep(500)
|
|
215
|
-
|
|
216
|
-
teardownAlwaysOnSession(sock, identity) // the bootout/shutdown path
|
|
217
|
-
// the whole server must be gone (the poller dies WITH the watcher — грабля closed)
|
|
218
|
-
expect(
|
|
219
|
-
await waitFor(
|
|
220
|
-
() => spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity], { stdio: 'ignore' }).status !== 0,
|
|
221
|
-
3000,
|
|
222
|
-
),
|
|
223
|
-
).toBe(true)
|
|
224
|
-
expect(await waitFor(() => !canaryRunning(identity), 3000)).toBe(true)
|
|
225
|
-
await sleep(300)
|
|
226
|
-
expect(existsSync(exitLogPath(logDir)) && readFileSync(exitLogPath(logDir), 'utf8').includes('ev=server-exit')).toBe(
|
|
227
|
-
false,
|
|
228
|
-
)
|
|
229
|
-
},
|
|
230
|
-
20000,
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
test(
|
|
234
|
-
'killSession (lifecycle clean reap) signals the canary before kill-server → no record',
|
|
235
|
-
async () => {
|
|
236
|
-
const identity = 'claude-canakill'
|
|
237
|
-
const { sock, logDir } = bringUp(identity)
|
|
238
|
-
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
|
|
239
|
-
expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
|
|
240
|
-
await sleep(500)
|
|
241
|
-
|
|
242
|
-
killSession(sock, identity) // last session → kills the SERVER, signal-first
|
|
243
|
-
expect(await waitFor(() => !canaryRunning(identity), 3000)).toBe(true)
|
|
244
|
-
await sleep(300)
|
|
245
|
-
expect(existsSync(exitLogPath(logDir)) && readFileSync(exitLogPath(logDir), 'utf8').includes('ev=server-exit')).toBe(
|
|
246
|
-
false,
|
|
247
|
-
)
|
|
248
|
-
},
|
|
249
|
-
20000,
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
test(
|
|
253
|
-
'death-#4 shape: external killer sweeps server AND canary client → ev=server-exit cause=client-signaled',
|
|
254
|
-
async () => {
|
|
255
|
-
const identity = 'claude-canasweep'
|
|
256
|
-
const { sock, logDir } = bringUp(identity)
|
|
257
|
-
const pid = serverPid(sock)
|
|
258
|
-
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
|
|
259
|
-
expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
|
|
260
|
-
await sleep(500)
|
|
261
|
-
|
|
262
|
-
// The pre-clean-shaped external killer: one pattern takes the server AND
|
|
263
|
-
// the canary CLIENT (both argv contain `tmux -S <sock> `), the sh recorder
|
|
264
|
-
// survives. The TERMed client returns rc=0 (proven live) — v1 read that as
|
|
265
|
-
// a clean channel signal and stayed silent; exactly how deaths #4–#6
|
|
266
|
-
// (10.06) left zero records. v2 probes the server instead: dead → record.
|
|
267
|
-
spawnSync('pkill', ['-f', `tmux -S ${sock} `], { stdio: 'ignore' })
|
|
268
|
-
const log = exitLogPath(logDir)
|
|
269
|
-
expect(
|
|
270
|
-
await waitFor(() => existsSync(log) && readFileSync(log, 'utf8').includes('ev=server-exit'), 10000),
|
|
271
|
-
).toBe(true)
|
|
272
|
-
const line = readFileSync(log, 'utf8')
|
|
273
|
-
expect(line).toContain(`ev=server-exit identity=${identity}`)
|
|
274
|
-
expect(line).toContain('cause=signaled-server-gone') // rc=0 shape, server found dead
|
|
275
|
-
expect(line).toContain(`server_pid=${pid}`)
|
|
276
|
-
expect(readdirSync(serverDeathsDir(logDir)).length).toBe(1) // forensics captured
|
|
277
|
-
},
|
|
278
|
-
20000,
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
test(
|
|
282
|
-
'canary client killed while server lives → silent (no false record), canary gone for the retrofit to re-arm',
|
|
283
|
-
async () => {
|
|
284
|
-
const identity = 'claude-canaclient'
|
|
285
|
-
const { sock, logDir } = bringUp(identity)
|
|
286
|
-
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
|
|
287
|
-
expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
|
|
288
|
-
await sleep(500)
|
|
289
|
-
|
|
290
|
-
// TERM the CLIENT only (argv ends with the bare channel — the sh's quoted
|
|
291
|
-
// form does not match this $-anchored pattern), server stays up.
|
|
292
|
-
spawnSync('pkill', ['-f', `wait-for ${canaryChannel(identity)}$`], { stdio: 'ignore' })
|
|
293
|
-
// the sh probes (≈2 s), finds the server ALIVE → exits silently
|
|
294
|
-
expect(await waitFor(() => !canaryRunning(identity), 6000)).toBe(true)
|
|
295
|
-
await sleep(300)
|
|
296
|
-
expect(
|
|
297
|
-
existsSync(exitLogPath(logDir)) && readFileSync(exitLogPath(logDir), 'utf8').includes('ev=server-exit'),
|
|
298
|
-
).toBe(false) // no false server-death while the server lives
|
|
299
|
-
// server must still be alive — and ensure re-arms a fresh canary (the
|
|
300
|
-
// retrofit path; in prod the supervise tick does this and logs it)
|
|
301
|
-
expect(spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity], { stdio: 'ignore' }).status).toBe(0)
|
|
302
|
-
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
|
|
303
|
-
},
|
|
304
|
-
20000,
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
test(
|
|
308
|
-
'dirty server death followed by immediate replacement on the same socket still records the ORIGINAL death',
|
|
309
|
-
async () => {
|
|
310
|
-
const identity = 'claude-canareplaced'
|
|
311
|
-
const { sock, logDir } = bringUp(identity)
|
|
312
|
-
const pid = serverPid(sock)
|
|
313
|
-
expect(pid).toBeGreaterThan(0)
|
|
314
|
-
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
|
|
315
|
-
expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
|
|
316
|
-
await sleep(500)
|
|
317
|
-
|
|
318
|
-
process.kill(pid, 'SIGKILL')
|
|
319
|
-
// Reuse the same socket before the canary's 2s dismissal-grace window
|
|
320
|
-
// expires. The old socket-level probe treated this successor as "server
|
|
321
|
-
// alive" and lost the predecessor's death; the canary must key on SPID.
|
|
322
|
-
let started = false
|
|
323
|
-
for (let i = 0; i < 20; i++) {
|
|
324
|
-
const r = spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', identity, 'sleep', '120'], {
|
|
325
|
-
stdio: 'ignore',
|
|
326
|
-
})
|
|
327
|
-
if (r.status === 0) {
|
|
328
|
-
started = true
|
|
329
|
-
break
|
|
330
|
-
}
|
|
331
|
-
await sleep(50)
|
|
332
|
-
}
|
|
333
|
-
expect(started).toBe(true)
|
|
334
|
-
|
|
335
|
-
const log = exitLogPath(logDir)
|
|
336
|
-
expect(await waitFor(() => existsSync(log) && readFileSync(log, 'utf8').includes('ev=server-exit'), 8000)).toBe(
|
|
337
|
-
true,
|
|
338
|
-
)
|
|
339
|
-
const line = readFileSync(log, 'utf8')
|
|
340
|
-
expect(line).toContain(`ev=server-exit identity=${identity}`)
|
|
341
|
-
expect(line).toContain(`server_pid=${pid}`)
|
|
342
|
-
expect(readdirSync(serverDeathsDir(logDir)).length).toBe(1)
|
|
343
|
-
},
|
|
344
|
-
20000,
|
|
345
|
-
)
|
|
346
|
-
})
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
// tmux command-log (cmdlog.ts) — the killer-hunt socket-command layer.
|
|
2
|
-
//
|
|
3
|
-
// The live suite encodes boris's acceptance criterion directly: a kill-session
|
|
4
|
-
// issued by an EXTERNAL client must land in the -v server log WITH client
|
|
5
|
-
// attribution (`message: client-<pid> command: …`), file modes never wider than
|
|
6
|
-
// 0600, and the cap keeps volume bounded without losing the tail.
|
|
7
|
-
|
|
8
|
-
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
9
|
-
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, existsSync } from 'fs'
|
|
10
|
-
import { tmpdir } from 'os'
|
|
11
|
-
import { join } from 'path'
|
|
12
|
-
import { spawnSync } from 'child_process'
|
|
13
|
-
import {
|
|
14
|
-
CMDLOG_KEEP_BYTES,
|
|
15
|
-
capCmdLogs,
|
|
16
|
-
cmdLogDirFor,
|
|
17
|
-
hardenCmdLogDir,
|
|
18
|
-
prepareCmdLogDir,
|
|
19
|
-
} from './cmdlog.ts'
|
|
20
|
-
|
|
21
|
-
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
22
|
-
|
|
23
|
-
let root: string
|
|
24
|
-
beforeAll(() => {
|
|
25
|
-
root = mkdtempSync(join(tmpdir(), 'cmdlog-'))
|
|
26
|
-
})
|
|
27
|
-
afterAll(() => {
|
|
28
|
-
rmSync(root, { recursive: true, force: true })
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
describe('prepareCmdLogDir / hardenCmdLogDir / capCmdLogs (hermetic)', () => {
|
|
32
|
-
test('prepare WIPES the previous generation and recreates 0700', () => {
|
|
33
|
-
const dir = prepareCmdLogDir(root, 'claude-x')
|
|
34
|
-
expect(dir).toBe(cmdLogDirFor(root, 'claude-x'))
|
|
35
|
-
writeFileSync(join(dir!, 'tmux-server-1.log'), 'old generation')
|
|
36
|
-
const again = prepareCmdLogDir(root, 'claude-x')
|
|
37
|
-
expect(readdirSync(again!)).toEqual([]) // old log gone — one generation per dir
|
|
38
|
-
expect(statSync(again!).mode & 0o777).toBe(0o700)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
test('harden clamps every file to 0600', () => {
|
|
42
|
-
const dir = prepareCmdLogDir(root, 'claude-h')!
|
|
43
|
-
writeFileSync(join(dir, 'tmux-server-2.log'), 'x', { mode: 0o644 })
|
|
44
|
-
hardenCmdLogDir(dir)
|
|
45
|
-
expect(statSync(join(dir, 'tmux-server-2.log')).mode & 0o777).toBe(0o600)
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
test('cap tail-keeps an oversized log (KEEP bytes of TAIL survive), leaves small logs alone', () => {
|
|
49
|
-
const dir = prepareCmdLogDir(root, 'claude-c')!
|
|
50
|
-
const big = join(dir, 'tmux-server-3.log')
|
|
51
|
-
const small = join(dir, 'tmux-server-4.log')
|
|
52
|
-
// 2 MB of filler ending in a recognizable tail — cap to 1 KB keep for the test
|
|
53
|
-
writeFileSync(big, Buffer.concat([Buffer.alloc(2 * 1024 * 1024, 0x61), Buffer.from('THE-DEATH-TAIL')]))
|
|
54
|
-
writeFileSync(small, 'tiny')
|
|
55
|
-
const capped = capCmdLogs(root, 1024 * 1024, 1024)
|
|
56
|
-
expect(capped).toEqual([big])
|
|
57
|
-
const after = readFileSync(big)
|
|
58
|
-
expect(after.length).toBe(1024)
|
|
59
|
-
expect(after.toString()).toEndWith('THE-DEATH-TAIL') // the tail is what survives
|
|
60
|
-
expect(readFileSync(small, 'utf8')).toBe('tiny')
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('cap on a missing tree is a silent no-op', () => {
|
|
64
|
-
expect(capCmdLogs(join(root, 'absent'))).toEqual([])
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
test('default keep constant stays under the cap', () => {
|
|
68
|
-
expect(CMDLOG_KEEP_BYTES).toBeLessThan(8 * 1024 * 1024)
|
|
69
|
-
})
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
describe.if(tmuxAvailable)('live -v server log (acceptance criterion)', () => {
|
|
73
|
-
test('external client kill-session lands in the server log with client attribution; harden → 0600', () => {
|
|
74
|
-
const dir = prepareCmdLogDir(root, 'claude-live')!
|
|
75
|
-
const sock = join(root, 'live.sock')
|
|
76
|
-
// Start the server FROM the cmdlog dir with -v — exactly the launch wiring.
|
|
77
|
-
const start = spawnSync('tmux', ['-S', sock, '-v', 'new-session', '-d', '-x', '80', '-y', '24', '-s', 'claude-live', 'sleep 60'], {
|
|
78
|
-
cwd: dir,
|
|
79
|
-
encoding: 'utf8',
|
|
80
|
-
})
|
|
81
|
-
expect(start.status).toBe(0)
|
|
82
|
-
hardenCmdLogDir(dir)
|
|
83
|
-
// An EXTERNAL client (separate tmux invocation = separate client pid) kills it.
|
|
84
|
-
const kill = spawnSync('tmux', ['-S', sock, 'kill-session', '-t', 'claude-live'], { encoding: 'utf8' })
|
|
85
|
-
expect(kill.status).toBe(0)
|
|
86
|
-
const serverLog = readdirSync(dir).find(f => f.startsWith('tmux-server-'))
|
|
87
|
-
expect(serverLog).toBeDefined()
|
|
88
|
-
expect(statSync(join(dir, serverLog!)).mode & 0o777).toBe(0o600)
|
|
89
|
-
const text = readFileSync(join(dir, serverLog!), 'utf8')
|
|
90
|
-
// Attribution line: message: client-<pid> command: kill-session -t claude-live
|
|
91
|
-
expect(text).toMatch(/message: client-\d+ command: kill-session -t claude-live/)
|
|
92
|
-
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
93
|
-
})
|
|
94
|
-
})
|