@agfpd/iapeer 0.2.76 → 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 +20 -13
- 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 -213
- 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
package/src/daemon/main.test.ts
DELETED
|
@@ -1,413 +0,0 @@
|
|
|
1
|
-
// Daemon production main — the daemon's OWN launchd plist (com.agfpd.iapeer),
|
|
2
|
-
// the composition smoke (startConfiguredDaemon returns a live TCP handle), and the
|
|
3
|
-
// DORMANT H8 bearer seam (off by default → no auth; on only when a token is set).
|
|
4
|
-
// All plist writes go under IAPEER_LAUNCHAGENTS_DIR so the suite never touches the
|
|
5
|
-
// real ~/Library/LaunchAgents.
|
|
6
|
-
|
|
7
|
-
import { afterEach, describe, expect, test } from 'bun:test'
|
|
8
|
-
import { spawnSync } from 'child_process'
|
|
9
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
10
|
-
import { tmpdir } from 'os'
|
|
11
|
-
import { join } from 'path'
|
|
12
|
-
import {
|
|
13
|
-
buildDaemonPlistSpec,
|
|
14
|
-
daemonPlistPath,
|
|
15
|
-
installDaemonPlist,
|
|
16
|
-
makeArmEphemeralOnDelivered,
|
|
17
|
-
makeEphemeralRouteDeps,
|
|
18
|
-
makeNoteLiveTopic,
|
|
19
|
-
startConfiguredDaemon,
|
|
20
|
-
DEFAULT_DAEMON_PORT,
|
|
21
|
-
} from './main.ts'
|
|
22
|
-
import { daemonDiscoveryPath, defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
|
|
23
|
-
import {
|
|
24
|
-
ephemeralQueueDepth,
|
|
25
|
-
hasEphemeralArmed,
|
|
26
|
-
peekEphemeralTask,
|
|
27
|
-
readTopic,
|
|
28
|
-
resolveWakeMode,
|
|
29
|
-
setIdleReaped,
|
|
30
|
-
writeTopic,
|
|
31
|
-
type LifecycleConfig,
|
|
32
|
-
} from '../lifecycle/index.ts'
|
|
33
|
-
import type { ResolvedCaller } from '../identity/index.ts'
|
|
34
|
-
import { isFoundationOwnedPlist } from '../launch/index.ts'
|
|
35
|
-
import { DAEMON_PLIST_LABEL } from '../core/constants.ts'
|
|
36
|
-
|
|
37
|
-
const tmpDirs: string[] = []
|
|
38
|
-
function mkTmp(): string {
|
|
39
|
-
const d = mkdtempSync(join(tmpdir(), 'iapeer-daemon-main-'))
|
|
40
|
-
tmpDirs.push(d)
|
|
41
|
-
return d
|
|
42
|
-
}
|
|
43
|
-
afterEach(() => {
|
|
44
|
-
while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true })
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
function plutilLint(path: string): boolean {
|
|
48
|
-
return spawnSync('plutil', ['-lint', path], { encoding: 'utf8' }).status === 0
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
describe('buildDaemonPlistSpec / installDaemonPlist (com.agfpd.iapeer)', () => {
|
|
52
|
-
test('spec carries the daemon label, default port env, and the INSTALLED iapeer entrypoint (Ф-F)', () => {
|
|
53
|
-
const spec = buildDaemonPlistSpec({ env: { HOME: '/Users/x' } as NodeJS.ProcessEnv })
|
|
54
|
-
expect(spec.label).toBe(DAEMON_PLIST_LABEL) // com.agfpd.iapeer — NOT com.iapeer.*
|
|
55
|
-
expect(spec.environment.IAPEER_PORT).toBe(String(DEFAULT_DAEMON_PORT))
|
|
56
|
-
// Ф-F: runs the INSTALLED binary `iapeer daemon`, NOT `bun <src>/main.ts` — prod
|
|
57
|
-
// decoupled from the mutable src tree.
|
|
58
|
-
expect(spec.programArguments).toEqual(['/Users/x/.local/bin/iapeer', 'daemon'])
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
test('NO bearer token in the plist env by default (H8 seam dormant)', () => {
|
|
62
|
-
expect(buildDaemonPlistSpec({ env: {} as NodeJS.ProcessEnv }).environment.IAPEER_BEARER_TOKEN).toBeUndefined()
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
test('bearer token is baked into the plist env only when explicitly provided', () => {
|
|
66
|
-
const spec = buildDaemonPlistSpec({ bearerToken: 's3cret', env: {} as NodeJS.ProcessEnv })
|
|
67
|
-
expect(spec.environment.IAPEER_BEARER_TOKEN).toBe('s3cret')
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
test('installs a valid, foundation-owned plist under IAPEER_LAUNCHAGENTS_DIR', () => {
|
|
71
|
-
const root = mkTmp()
|
|
72
|
-
const env = {
|
|
73
|
-
IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
|
|
74
|
-
IAPEER_ROOT: join(root, 'iapeer'),
|
|
75
|
-
HOME: root,
|
|
76
|
-
PATH: '/usr/bin:/bin',
|
|
77
|
-
} as NodeJS.ProcessEnv
|
|
78
|
-
const r = installDaemonPlist({ env, port: 8765, throttleIntervalSecs: 10 })
|
|
79
|
-
expect(r.path).toBe(daemonPlistPath(env))
|
|
80
|
-
expect(r.changed).toBe(true) // first install on a clean host writes
|
|
81
|
-
expect(existsSync(r.path)).toBe(true)
|
|
82
|
-
expect(isFoundationOwnedPlist(r.path)).toBe(true)
|
|
83
|
-
expect(plutilLint(r.path)).toBe(true) // live plutil: valid plist
|
|
84
|
-
const xml = readFileSync(r.path, 'utf8')
|
|
85
|
-
expect(xml).toContain(`<string>${DAEMON_PLIST_LABEL}</string>`)
|
|
86
|
-
expect(xml).toContain('<key>RunAtLoad</key>')
|
|
87
|
-
expect(xml).toContain('<key>KeepAlive</key>')
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
test('idempotent by content: a re-install of the SAME plist does NOT rewrite the file (changed:false, mtime stable) — no BTM notification spam (owner-caught 11.06)', () => {
|
|
91
|
-
const root = mkTmp()
|
|
92
|
-
const env = {
|
|
93
|
-
IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
|
|
94
|
-
IAPEER_ROOT: join(root, 'iapeer'),
|
|
95
|
-
HOME: root,
|
|
96
|
-
PATH: '/usr/bin:/bin',
|
|
97
|
-
} as NodeJS.ProcessEnv
|
|
98
|
-
const opts = { env, port: 8765, throttleIntervalSecs: 10 }
|
|
99
|
-
const first = installDaemonPlist(opts)
|
|
100
|
-
expect(first.changed).toBe(true)
|
|
101
|
-
const mtime1 = statSync(first.path).mtimeMs
|
|
102
|
-
// second identical install — must be a pure no-op (no write → BTM not tripped)
|
|
103
|
-
const second = installDaemonPlist(opts)
|
|
104
|
-
expect(second.changed).toBe(false)
|
|
105
|
-
expect(second.path).toBe(first.path)
|
|
106
|
-
expect(statSync(second.path).mtimeMs).toBe(mtime1) // file untouched, not just byte-equal
|
|
107
|
-
// a CHANGED spec (different port → different rendered plist) DOES rewrite
|
|
108
|
-
const third = installDaemonPlist({ env, port: 9999, throttleIntervalSecs: 10 })
|
|
109
|
-
expect(third.changed).toBe(true)
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test('REFUSES to overwrite a foreign com.agfpd.iapeer.plist (collision guard)', () => {
|
|
113
|
-
const root = mkTmp()
|
|
114
|
-
const laDir = join(root, 'LaunchAgents')
|
|
115
|
-
mkdirSync(laDir, { recursive: true })
|
|
116
|
-
const env = { IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_ROOT: join(root, 'iapeer'), HOME: root } as NodeJS.ProcessEnv
|
|
117
|
-
const path = daemonPlistPath(env)
|
|
118
|
-
const foreign = '<?xml version="1.0"?>\n<plist><dict><key>Label</key><string>com.agfpd.iapeer</string></dict></plist>\n'
|
|
119
|
-
writeFileSync(path, foreign)
|
|
120
|
-
expect(() => installDaemonPlist({ env })).toThrow(/foundation-managed|refus/i)
|
|
121
|
-
expect(readFileSync(path, 'utf8')).toBe(foreign) // untouched
|
|
122
|
-
// (the throw must fire BEFORE the write-if-changed read, so the foreign file
|
|
123
|
-
// is never even compared — the guard is the first gate.)
|
|
124
|
-
})
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
describe('startConfiguredDaemon (composition smoke — TCP loopback)', () => {
|
|
128
|
-
test('returns a live http handle and closes cleanly', async () => {
|
|
129
|
-
const root = mkTmp()
|
|
130
|
-
const env = { IAPEER_ROOT: join(root, 'iapeer'), HOME: root, PATH: '/usr/bin:/bin' } as NodeJS.ProcessEnv
|
|
131
|
-
const handle = await startConfiguredDaemon({ port: 0, host: '127.0.0.1', env })
|
|
132
|
-
try {
|
|
133
|
-
expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/)
|
|
134
|
-
} finally {
|
|
135
|
-
await handle.close()
|
|
136
|
-
}
|
|
137
|
-
})
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
// The H8 bearer seam: a validator LAYER on the listener, OFF unless a token is set
|
|
141
|
-
// (Артур 07.06 — H8 DEFERRED, no token provisioning yet). These tests prove the
|
|
142
|
-
// seam is dormant by default and would gate every request once a token is wired.
|
|
143
|
-
describe('H8 bearer seam (dormant by default)', () => {
|
|
144
|
-
const INIT = JSON.stringify({
|
|
145
|
-
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
146
|
-
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 't', version: '0' } },
|
|
147
|
-
})
|
|
148
|
-
const headers = (auth?: string): Record<string, string> => ({
|
|
149
|
-
'content-type': 'application/json',
|
|
150
|
-
accept: 'application/json, text/event-stream',
|
|
151
|
-
...(auth ? { authorization: auth } : {}),
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
async function withDaemon(opts: Parameters<typeof startDaemon>[0], fn: (h: DaemonHandle) => Promise<void>) {
|
|
155
|
-
const h = await startDaemon(opts)
|
|
156
|
-
try {
|
|
157
|
-
await fn(h)
|
|
158
|
-
} finally {
|
|
159
|
-
await h.close()
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
test('NO token → request is NOT 401 (auth layer off)', async () => {
|
|
164
|
-
await withDaemon({ port: 0, host: '127.0.0.1' }, async h => {
|
|
165
|
-
const res = await fetch(h.url!, { method: 'POST', headers: headers(), body: INIT })
|
|
166
|
-
expect(res.status).not.toBe(401)
|
|
167
|
-
})
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
test('token set → no Authorization is 401, correct Bearer passes the layer', async () => {
|
|
171
|
-
await withDaemon({ port: 0, host: '127.0.0.1', bearerToken: 'secret' }, async h => {
|
|
172
|
-
const noAuth = await fetch(h.url!, { method: 'POST', headers: headers(), body: INIT })
|
|
173
|
-
expect(noAuth.status).toBe(401)
|
|
174
|
-
const wrong = await fetch(h.url!, { method: 'POST', headers: headers('Bearer nope'), body: INIT })
|
|
175
|
-
expect(wrong.status).toBe(401)
|
|
176
|
-
const ok = await fetch(h.url!, { method: 'POST', headers: headers('Bearer secret'), body: INIT })
|
|
177
|
-
expect(ok.status).not.toBe(401) // passed the auth layer (MCP handles the rest)
|
|
178
|
-
await ok.body?.cancel()
|
|
179
|
-
})
|
|
180
|
-
})
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
// Dual-listen (Ф2 consolidation): the daemon serves a 0600 unix socket (local
|
|
184
|
-
// same-uid callers: notifier/telegram/CLI) AND TCP (agent MCP) over ONE handler,
|
|
185
|
-
// and writes router.json (both addresses) for daemon-aware `iap send`.
|
|
186
|
-
describe('dual-listen + router.json discovery', () => {
|
|
187
|
-
const INIT = JSON.stringify({
|
|
188
|
-
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
189
|
-
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 't', version: '0' } },
|
|
190
|
-
})
|
|
191
|
-
const mcpHeaders = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }
|
|
192
|
-
|
|
193
|
-
test('binds socket + TCP over one handler; router.json carries both; close cleans up', async () => {
|
|
194
|
-
const root = mkTmp()
|
|
195
|
-
const env = { IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
|
|
196
|
-
const sockExpected = defaultDaemonSocketPath({ env })
|
|
197
|
-
const h = await startDaemon({ port: 0, host: '127.0.0.1', socketPath: sockExpected, discovery: true, env })
|
|
198
|
-
try {
|
|
199
|
-
// TCP listener answers the MCP handler
|
|
200
|
-
const tcp = await fetch(h.url!, { method: 'POST', headers: mcpHeaders, body: INIT })
|
|
201
|
-
expect(tcp.ok).toBe(true)
|
|
202
|
-
await tcp.body?.cancel()
|
|
203
|
-
// unix-socket listener answers the SAME handler (bun fetch `unix` option)
|
|
204
|
-
const unixInit = { unix: h.socketPath!, method: 'POST', headers: mcpHeaders, body: INIT } as unknown as RequestInit
|
|
205
|
-
const sock = await fetch('http://localhost/mcp', unixInit)
|
|
206
|
-
expect(sock.ok).toBe(true)
|
|
207
|
-
await sock.body?.cancel()
|
|
208
|
-
// socket is 0600 (same-uid)
|
|
209
|
-
expect((statSync(h.socketPath!).mode & 0o777).toString(8)).toBe('600')
|
|
210
|
-
// router.json carries BOTH addresses
|
|
211
|
-
const rj = JSON.parse(readFileSync(daemonDiscoveryPath({ env }), 'utf8'))
|
|
212
|
-
expect(rj.sock).toBe(sockExpected)
|
|
213
|
-
expect(rj.tcp).toBe(h.url)
|
|
214
|
-
} finally {
|
|
215
|
-
await h.close()
|
|
216
|
-
}
|
|
217
|
-
// clean shutdown removed the socket file AND router.json
|
|
218
|
-
expect(existsSync(sockExpected)).toBe(false)
|
|
219
|
-
expect(existsSync(daemonDiscoveryPath({ env }))).toBe(false)
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
test('port-only stays TCP-only — no socket, no router.json (backward compat)', async () => {
|
|
223
|
-
const root = mkTmp()
|
|
224
|
-
const env = { IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
|
|
225
|
-
const h = await startDaemon({ port: 0, host: '127.0.0.1', env })
|
|
226
|
-
try {
|
|
227
|
-
expect(h.socketPath).toBeUndefined()
|
|
228
|
-
expect(existsSync(daemonDiscoveryPath({ env }))).toBe(false)
|
|
229
|
-
} finally {
|
|
230
|
-
await h.close()
|
|
231
|
-
}
|
|
232
|
-
})
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
describe('startDaemon composerQueue shutdown seam', () => {
|
|
236
|
-
test('close() fails pending busy-composer queue before tearing listeners down', async () => {
|
|
237
|
-
const reasons: string[] = []
|
|
238
|
-
const h = await startDaemon({
|
|
239
|
-
port: 0,
|
|
240
|
-
host: '127.0.0.1',
|
|
241
|
-
composerQueue: {
|
|
242
|
-
tryEnqueue: async () => null,
|
|
243
|
-
failAll: async reason => {
|
|
244
|
-
reasons.push(reason)
|
|
245
|
-
},
|
|
246
|
-
},
|
|
247
|
-
})
|
|
248
|
-
await h.close()
|
|
249
|
-
expect(reasons).toEqual(['daemon shutting down/restarting before queued delivery completed'])
|
|
250
|
-
})
|
|
251
|
-
})
|
|
252
|
-
|
|
253
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
254
|
-
// wake_policy:ephemeral M2 — makeArmEphemeralOnDelivered (the arm-on-outbound
|
|
255
|
-
// composition: ephemeral caller's ok send ⇒ .ephemeral-armed for its identity)
|
|
256
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
257
|
-
|
|
258
|
-
describe('makeArmEphemeralOnDelivered (M2 arm-on-outbound)', () => {
|
|
259
|
-
function caller(cwd: string): ResolvedCaller {
|
|
260
|
-
return {
|
|
261
|
-
personality: 'w',
|
|
262
|
-
runtime: 'claude',
|
|
263
|
-
address: 'claude-w',
|
|
264
|
-
description: '',
|
|
265
|
-
intelligence: 'artificial',
|
|
266
|
-
cwd,
|
|
267
|
-
record: { personality: 'w', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd },
|
|
268
|
-
} as ResolvedCaller
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
test('EPHEMERAL caller → armed for the CALLER identity; plain caller → no-op', () => {
|
|
272
|
-
const stateDir = join(mkTmp(), 'lifecycle')
|
|
273
|
-
const cfg = { stateDir } as LifecycleConfig
|
|
274
|
-
const arm = makeArmEphemeralOnDelivered(cfg)
|
|
275
|
-
// ephemeral worker cwd
|
|
276
|
-
const eph = mkTmp()
|
|
277
|
-
mkdirSync(join(eph, '.iapeer'), { recursive: true })
|
|
278
|
-
writeFileSync(
|
|
279
|
-
join(eph, '.iapeer', 'peer-profile.json'),
|
|
280
|
-
JSON.stringify({ personality: 'w', runtime: 'claude', wake_policy: 'ephemeral' }),
|
|
281
|
-
)
|
|
282
|
-
arm(caller(eph))
|
|
283
|
-
expect(hasEphemeralArmed(cfg, 'claude-w')).toBe(true)
|
|
284
|
-
// plain peer cwd (no wake_policy) → never armed
|
|
285
|
-
const plainCfg = { stateDir: join(mkTmp(), 'lifecycle') } as LifecycleConfig
|
|
286
|
-
const plain = mkTmp()
|
|
287
|
-
mkdirSync(join(plain, '.iapeer'), { recursive: true })
|
|
288
|
-
writeFileSync(
|
|
289
|
-
join(plain, '.iapeer', 'peer-profile.json'),
|
|
290
|
-
JSON.stringify({ personality: 'w', runtime: 'claude' }),
|
|
291
|
-
)
|
|
292
|
-
makeArmEphemeralOnDelivered(plainCfg)(caller(plain))
|
|
293
|
-
expect(hasEphemeralArmed(plainCfg, 'claude-w')).toBe(false)
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
test('missing profile / unreadable cwd → no-op, never throws (best-effort)', () => {
|
|
297
|
-
const cfg = { stateDir: join(mkTmp(), 'lifecycle') } as LifecycleConfig
|
|
298
|
-
const arm = makeArmEphemeralOnDelivered(cfg)
|
|
299
|
-
expect(() => arm(caller('/tmp/no-such-peer-cwd-anywhere'))).not.toThrow()
|
|
300
|
-
expect(hasEphemeralArmed(cfg, 'claude-w')).toBe(false)
|
|
301
|
-
})
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
305
|
-
// wake_policy:"ephemeral" M3 — makeEphemeralRouteDeps (enqueue + async drain kick)
|
|
306
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
307
|
-
|
|
308
|
-
describe('makeEphemeralRouteDeps (M3 serial-queue composition)', () => {
|
|
309
|
-
const workerPeer = {
|
|
310
|
-
personality: 'w',
|
|
311
|
-
runtime: 'claude' as const,
|
|
312
|
-
runtimes: ['claude' as const],
|
|
313
|
-
description: '',
|
|
314
|
-
intelligence: 'artificial' as const,
|
|
315
|
-
cwd: '/tmp/w',
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
test('deliver = enqueue to disk + fast {queued, qd} ack + drain kick (NOT awaited wake)', async () => {
|
|
319
|
-
const stateDir = join(mkTmp(), 'lifecycle')
|
|
320
|
-
const cfg = { stateDir } as LifecycleConfig
|
|
321
|
-
const kicks: string[] = []
|
|
322
|
-
const deps = makeEphemeralRouteDeps(cfg, {} as NodeJS.ProcessEnv, (p, rt) => kicks.push(`${rt}-${p}`))
|
|
323
|
-
|
|
324
|
-
const r1 = await deps.deliver({ peer: workerPeer, envelope: '<iap>task one</iap>', topic: 't1' })
|
|
325
|
-
const r2 = await deps.deliver({ peer: workerPeer, envelope: '<iap>task two</iap>' })
|
|
326
|
-
|
|
327
|
-
expect(r1.ok && r1.value.queued).toBe(true)
|
|
328
|
-
expect(r1.ok && r1.value.queuedBy).toBe('ephemeral')
|
|
329
|
-
expect(r1.ok && r1.value.queueDepth).toBe(1)
|
|
330
|
-
expect(r1.ok && r1.value.woke).toBe(false)
|
|
331
|
-
expect(r2.ok && r2.value.queueDepth).toBe(2)
|
|
332
|
-
expect(kicks).toEqual(['claude-w', 'claude-w']) // drain kicked per enqueue
|
|
333
|
-
// durable FIFO on disk, head intact
|
|
334
|
-
expect(ephemeralQueueDepth(cfg, 'claude-w')).toBe(2)
|
|
335
|
-
expect(peekEphemeralTask(cfg, 'claude-w')).toMatchObject({ task: '<iap>task one</iap>', topic: 't1' })
|
|
336
|
-
})
|
|
337
|
-
|
|
338
|
-
test('enqueue failure → fail-loud delivery error (never a false queued ack)', async () => {
|
|
339
|
-
const root = mkTmp()
|
|
340
|
-
const blocker = join(root, 'state-is-a-file')
|
|
341
|
-
writeFileSync(blocker, 'not a dir')
|
|
342
|
-
const cfg = { stateDir: join(blocker, 'lifecycle') } as LifecycleConfig // mkdir must fail
|
|
343
|
-
const deps = makeEphemeralRouteDeps(cfg, {} as NodeJS.ProcessEnv, () => {})
|
|
344
|
-
const r = await deps.deliver({ peer: workerPeer, envelope: 'x' })
|
|
345
|
-
expect(r.ok).toBe(false)
|
|
346
|
-
if (!r.ok) expect(r.error.message).toMatch(/enqueue failed/)
|
|
347
|
-
})
|
|
348
|
-
|
|
349
|
-
test('runtime override resolves through the registry gate (undeclared → fail-loud)', async () => {
|
|
350
|
-
const cfg = { stateDir: join(mkTmp(), 'lifecycle') } as LifecycleConfig
|
|
351
|
-
const deps = makeEphemeralRouteDeps(cfg, {} as NodeJS.ProcessEnv, () => {})
|
|
352
|
-
const r = await deps.deliver({ peer: workerPeer, envelope: 'x', runtime: 'codex' })
|
|
353
|
-
expect(r.ok).toBe(false) // 'codex' is not declared for this worker
|
|
354
|
-
})
|
|
355
|
-
})
|
|
356
|
-
|
|
357
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
358
|
-
// makeNoteLiveTopic — the live-delivered-topic composition (fresh-vs-resume seam):
|
|
359
|
-
// the .topic marker must track the topic the session last WORKED ON, not the one
|
|
360
|
-
// it woke with (defect 11.06: a day-stale wake-time marker false-freshed the
|
|
361
|
-
// stop→start resume of a long-lived executor).
|
|
362
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
363
|
-
|
|
364
|
-
describe('makeNoteLiveTopic (live-delivered topic → .topic marker)', () => {
|
|
365
|
-
function noteCfg(): LifecycleConfig {
|
|
366
|
-
const root = mkTmp()
|
|
367
|
-
return { stateDir: join(root, 'state'), eventLogDir: join(root, 'logs') } as LifecycleConfig
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
test('topic SHIFT → marker updated + ev=topic-note logged; same topic → no churn, no log spam', () => {
|
|
371
|
-
const cfg = noteCfg()
|
|
372
|
-
const note = makeNoteLiveTopic(cfg, {} as NodeJS.ProcessEnv)
|
|
373
|
-
writeTopic(cfg, 'claude-x', 'topic-a') // the wake established topic A
|
|
374
|
-
note('claude-x', 'topic-b') // a live delivery switches the work to B
|
|
375
|
-
expect(readTopic(cfg, 'claude-x')).toBe('topic-b')
|
|
376
|
-
const logPath = join(cfg.eventLogDir, 'lifecycle.log')
|
|
377
|
-
const log = readFileSync(logPath, 'utf8')
|
|
378
|
-
expect(log).toContain('ev=topic-note')
|
|
379
|
-
expect(log).toContain('identity=claude-x')
|
|
380
|
-
expect(log).toContain('topic=topic-b')
|
|
381
|
-
// the common case — every further message continues topic B → strictly nothing
|
|
382
|
-
note('claude-x', 'topic-b')
|
|
383
|
-
note('claude-x', 'topic-b')
|
|
384
|
-
expect(readFileSync(logPath, 'utf8').match(/ev=topic-note/g)?.length).toBe(1)
|
|
385
|
-
})
|
|
386
|
-
|
|
387
|
-
test('acceptance scenario (boris 11.06): woke with topic A → worked live-delivered topic B → clean stop-park → wake with topic B = RESUME, not fresh', () => {
|
|
388
|
-
const cfg = noteCfg()
|
|
389
|
-
const id = 'claude-x'
|
|
390
|
-
// executor peer cwd: no telegram interface (not human-conversational), no wake_policy
|
|
391
|
-
const cwd = mkTmp()
|
|
392
|
-
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
393
|
-
writeFileSync(join(cwd, '.iapeer', 'peer-profile.json'), JSON.stringify({ personality: 'x', runtime: 'claude' }))
|
|
394
|
-
writeTopic(cfg, id, 'topic-a') // session woke with topic A (the stale pre-fix marker)
|
|
395
|
-
makeNoteLiveTopic(cfg)(id, 'topic-b') // ...then took topic-B messages in the LIVE session all day
|
|
396
|
-
setIdleReaped(cfg, id) // deliberate stop = clean park (stopPeer's promise)
|
|
397
|
-
const hasTranscript = () => ({ ok: true, ref: 'uuid-1' })
|
|
398
|
-
// pre-fix this read the stale 'topic-a' → cause=idle-reaped-new-topic → FRESH (context lost)
|
|
399
|
-
expect(resolveWakeMode(cfg, id, cwd, undefined, hasTranscript, 'topic-b')).toEqual({
|
|
400
|
-
resume: true,
|
|
401
|
-
resumeRef: 'uuid-1',
|
|
402
|
-
cause: 'idle-reaped-resume',
|
|
403
|
-
})
|
|
404
|
-
})
|
|
405
|
-
|
|
406
|
-
test('marker/log failure → no-op, never throws (a delivered message must stand)', () => {
|
|
407
|
-
const root = mkTmp()
|
|
408
|
-
const blocker = join(root, 'state-is-a-file')
|
|
409
|
-
writeFileSync(blocker, 'not a dir')
|
|
410
|
-
const cfg = { stateDir: join(blocker, 'state'), eventLogDir: join(blocker, 'logs') } as LifecycleConfig // mkdir must fail
|
|
411
|
-
expect(() => makeNoteLiveTopic(cfg)('claude-x', 'topic-b')).not.toThrow()
|
|
412
|
-
})
|
|
413
|
-
})
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
// enable — the PURE fleet-safety + idempotency logic: parse `claude plugin list
|
|
2
|
-
// --json`, match the entry for THIS peer's projectPath (realpath), and detect a
|
|
3
|
-
// plugin's `setup` from iapeer.json (SIMPLE vs СЛОЖНЫЙ). The spawn/install paths are
|
|
4
|
-
// live-verified on a throwaway test-peer (project-scope is keyed by projectPath, so a
|
|
5
|
-
// test install never touches a live peer's entry).
|
|
6
|
-
|
|
7
|
-
import { describe, expect, test } from 'bun:test'
|
|
8
|
-
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
9
|
-
import { tmpdir } from 'os'
|
|
10
|
-
import { join } from 'path'
|
|
11
|
-
import { findPeerScopedEntry, parseCodexPluginStatus, parseInstalledPlugins, readSetupDescriptor } from './index.ts'
|
|
12
|
-
|
|
13
|
-
// a realistic `claude plugin list --json` array (the live shape: id/scope/enabled/
|
|
14
|
-
// projectPath/installPath), two live peers + the same plugin user-scope.
|
|
15
|
-
const LIST = JSON.stringify([
|
|
16
|
-
{ id: 'Inter-Agent-Protocol@agfpd', scope: 'project', enabled: true, projectPath: '/Users/x/agents/linus', installPath: '/c/iap/0.7.11' },
|
|
17
|
-
{ id: 'peer-voice@agfpd', scope: 'project', enabled: true, projectPath: '/Users/x/agents/boris', installPath: '/c/pv/0.1.8' },
|
|
18
|
-
{ id: 'peer-voice@agfpd', scope: 'user', enabled: false, installPath: '/c/pv/0.1.8' },
|
|
19
|
-
])
|
|
20
|
-
|
|
21
|
-
describe('parseInstalledPlugins', () => {
|
|
22
|
-
test('parses the flat array into typed entries; tolerates junk', () => {
|
|
23
|
-
const e = parseInstalledPlugins(LIST)
|
|
24
|
-
expect(e.length).toBe(3)
|
|
25
|
-
expect(e[0]).toMatchObject({ id: 'Inter-Agent-Protocol@agfpd', scope: 'project', enabled: true, projectPath: '/Users/x/agents/linus' })
|
|
26
|
-
expect(parseInstalledPlugins('not json')).toEqual([])
|
|
27
|
-
expect(parseInstalledPlugins('{"not":"array"}')).toEqual([])
|
|
28
|
-
expect(parseInstalledPlugins('[1, null, {"no":"id"}]')).toEqual([]) // entries without a string id are dropped
|
|
29
|
-
})
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
describe('findPeerScopedEntry (per-peer, fleet-safe)', () => {
|
|
33
|
-
test('matches ONLY the project-scope entry for this peer cwd', () => {
|
|
34
|
-
const e = parseInstalledPlugins(LIST)
|
|
35
|
-
expect(findPeerScopedEntry(e, 'peer-voice', 'agfpd', '/Users/x/agents/boris')?.enabled).toBe(true)
|
|
36
|
-
// a DIFFERENT peer cwd → no match (never reports another peer's install as ours)
|
|
37
|
-
expect(findPeerScopedEntry(e, 'peer-voice', 'agfpd', '/Users/x/agents/darwin')).toBeNull()
|
|
38
|
-
// user-scope is not a per-peer match
|
|
39
|
-
expect(findPeerScopedEntry(e, 'peer-voice', 'agfpd', '/Users/x/agents/boris')?.scope).toBe('project')
|
|
40
|
-
})
|
|
41
|
-
test('absent plugin for this peer → null (drives install, not false-skip)', () => {
|
|
42
|
-
const e = parseInstalledPlugins(LIST)
|
|
43
|
-
expect(findPeerScopedEntry(e, 'MergeMind', 'agfpd', '/Users/x/agents/linus')).toBeNull()
|
|
44
|
-
})
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
// the real `codex plugin list --json` shape (codex-cli 0.138, live-captured fields)
|
|
48
|
-
const CODEX_LIST = JSON.stringify({
|
|
49
|
-
installed: [
|
|
50
|
-
{ pluginId: 'peer-voice@agfpd', installed: true, enabled: true },
|
|
51
|
-
{ pluginId: 'MergeMind@agfpd', installed: true, enabled: false },
|
|
52
|
-
{ pluginId: 'spawned-peer@agfpd', installed: false, enabled: false }, // marketplace-known, not installed
|
|
53
|
-
],
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
describe('parseCodexPluginStatus', () => {
|
|
57
|
-
test('reads the installed/enabled flags per plugin id', () => {
|
|
58
|
-
expect(parseCodexPluginStatus(CODEX_LIST, 'peer-voice@agfpd')).toBe('enabled')
|
|
59
|
-
expect(parseCodexPluginStatus(CODEX_LIST, 'spawned-peer@agfpd')).toBe('absent') // installed:false
|
|
60
|
-
expect(parseCodexPluginStatus(CODEX_LIST, 'MergeMind@agfpd')).toBe('disabled') // installed, not enabled
|
|
61
|
-
expect(parseCodexPluginStatus(CODEX_LIST, 'totp-presence@agfpd')).toBe('absent') // not in array
|
|
62
|
-
expect(parseCodexPluginStatus('', 'x@agfpd')).toBe('absent') // empty
|
|
63
|
-
expect(parseCodexPluginStatus('not json', 'x@agfpd')).toBe('absent') // malformed → fail-safe
|
|
64
|
-
expect(parseCodexPluginStatus('{"no":"installed-array"}', 'x@agfpd')).toBe('absent')
|
|
65
|
-
})
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
describe('readSetupDescriptor (SIMPLE vs СЛОЖНЫЙ)', () => {
|
|
69
|
-
test('no installPath / no iapeer.json → null (SIMPLE: install+enable only)', () => {
|
|
70
|
-
expect(readSetupDescriptor(undefined)).toBeNull()
|
|
71
|
-
const dir = mkdtempSync(join(tmpdir(), 'iapeer-enable-simple-'))
|
|
72
|
-
try {
|
|
73
|
-
expect(readSetupDescriptor(dir)).toBeNull() // peer-voice-like: no manifest
|
|
74
|
-
} finally {
|
|
75
|
-
rmSync(dir, { recursive: true, force: true })
|
|
76
|
-
}
|
|
77
|
-
})
|
|
78
|
-
test('iapeer.json with setup string → returns it; with {command,args} → object', () => {
|
|
79
|
-
const dir = mkdtempSync(join(tmpdir(), 'iapeer-enable-complex-'))
|
|
80
|
-
try {
|
|
81
|
-
writeFileSync(join(dir, 'iapeer.json'), JSON.stringify({ setup: 'bin/setup' }))
|
|
82
|
-
expect(readSetupDescriptor(dir)).toBe('bin/setup')
|
|
83
|
-
writeFileSync(join(dir, 'iapeer.json'), JSON.stringify({ setup: { command: 'node', args: ['setup.js'] } }))
|
|
84
|
-
expect(readSetupDescriptor(dir)).toEqual({ command: 'node', args: ['setup.js'] })
|
|
85
|
-
// requires present but NO setup → still SIMPLE (iapeer does not resolve requires)
|
|
86
|
-
writeFileSync(join(dir, 'iapeer.json'), JSON.stringify({ requires: ['telegram'] }))
|
|
87
|
-
expect(readSetupDescriptor(dir)).toBeNull()
|
|
88
|
-
// malformed manifest → treated as SIMPLE, never throws
|
|
89
|
-
writeFileSync(join(dir, 'iapeer.json'), '{ broken')
|
|
90
|
-
expect(readSetupDescriptor(dir)).toBeNull()
|
|
91
|
-
} finally {
|
|
92
|
-
rmSync(dir, { recursive: true, force: true })
|
|
93
|
-
}
|
|
94
|
-
})
|
|
95
|
-
})
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
// Provision-command executor — the v1.2 inversion joint. Pins the four contract
|
|
2
|
-
// requirements: per-arg placeholder substitution WITHOUT a shell (injection
|
|
3
|
-
// class), absolute command, timeout, structured outcomes. The fake provider is
|
|
4
|
-
// a tmp shell script that journals its argv — no real provider involved.
|
|
5
|
-
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
7
|
-
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
8
|
-
import { tmpdir } from 'os'
|
|
9
|
-
import { join } from 'path'
|
|
10
|
-
import { runProvisionCommand } from './provisionCommand.ts'
|
|
11
|
-
|
|
12
|
-
let dir: string
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
dir = mkdtempSync(join(tmpdir(), 'iapeer-provcmd-'))
|
|
15
|
-
})
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
rmSync(dir, { recursive: true, force: true })
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
/** A fake provider command journaling each argv element on its own line. */
|
|
21
|
-
function fakeProvider(name: string, body?: string): string {
|
|
22
|
-
const p = join(dir, name)
|
|
23
|
-
writeFileSync(p, `#!/bin/sh\n${body ?? `printf '%s\\n' "$@" > '${dir}/journal.txt'`}\n`)
|
|
24
|
-
chmodSync(p, 0o755)
|
|
25
|
-
return p
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
describe('runProvisionCommand (v1.2 executor)', () => {
|
|
29
|
-
test('per-arg substitution of {cwd}/{runtime}/{personality}/{occasion}, argv passed verbatim (no shell)', () => {
|
|
30
|
-
const cmd = fakeProvider('prov.sh')
|
|
31
|
-
const o = runProvisionCommand({
|
|
32
|
-
block: {
|
|
33
|
-
command: cmd,
|
|
34
|
-
// both shapes: bare placeholder arg and embedded `--k={v}`; plus an arg
|
|
35
|
-
// full of shell metacharacters that MUST stay literal (no shell layer)
|
|
36
|
-
args: ['provision-peer', '--cwd', '{cwd}', '--runtime={runtime}', '--occasion', '{occasion}', '$(reboot) `id` ; rm -rf /'],
|
|
37
|
-
},
|
|
38
|
-
cwd: '/tmp/some peer dir',
|
|
39
|
-
runtime: 'claude',
|
|
40
|
-
personality: 'tw-prov',
|
|
41
|
-
occasion: 'birth',
|
|
42
|
-
})
|
|
43
|
-
expect(o.state).toBe('ok')
|
|
44
|
-
expect(o.exitCode).toBe(0)
|
|
45
|
-
const journal = readFileSync(join(dir, 'journal.txt'), 'utf8').split('\n')
|
|
46
|
-
expect(journal[0]).toBe('provision-peer')
|
|
47
|
-
expect(journal[1]).toBe('--cwd')
|
|
48
|
-
expect(journal[2]).toBe('/tmp/some peer dir') // spaces survive — single argv element
|
|
49
|
-
expect(journal[3]).toBe('--runtime=claude') // embedded substitution
|
|
50
|
-
expect(journal[4]).toBe('--occasion')
|
|
51
|
-
expect(journal[5]).toBe('birth')
|
|
52
|
-
expect(journal[6]).toBe('$(reboot) `id` ; rm -rf /') // LITERAL — never shell-parsed
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
test('{personality} placeholder is supported (optional for the provider)', () => {
|
|
56
|
-
const cmd = fakeProvider('prov.sh')
|
|
57
|
-
const o = runProvisionCommand({
|
|
58
|
-
block: { command: cmd, args: ['{personality}'] },
|
|
59
|
-
cwd: '/x',
|
|
60
|
-
runtime: 'codex',
|
|
61
|
-
personality: 'boris',
|
|
62
|
-
occasion: 'sweep-on',
|
|
63
|
-
})
|
|
64
|
-
expect(o.state).toBe('ok')
|
|
65
|
-
expect(readFileSync(join(dir, 'journal.txt'), 'utf8').trim()).toBe('boris')
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
test('non-zero exit → failed with exit code and stderr tail', () => {
|
|
69
|
-
const cmd = fakeProvider('prov.sh', `echo 'no such peer' >&2; exit 3`)
|
|
70
|
-
const o = runProvisionCommand({
|
|
71
|
-
block: { command: cmd, args: [] },
|
|
72
|
-
cwd: '/x',
|
|
73
|
-
runtime: 'claude',
|
|
74
|
-
personality: 'p',
|
|
75
|
-
occasion: 'off-peer',
|
|
76
|
-
})
|
|
77
|
-
expect(o.state).toBe('failed')
|
|
78
|
-
expect(o.exitCode).toBe(3)
|
|
79
|
-
expect(o.detail).toContain('no such peer')
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
test('timeout → state=timeout, never hangs the joint', () => {
|
|
83
|
-
const cmd = fakeProvider('prov.sh', 'sleep 30')
|
|
84
|
-
const o = runProvisionCommand({
|
|
85
|
-
block: { command: cmd, args: [] },
|
|
86
|
-
cwd: '/x',
|
|
87
|
-
runtime: 'claude',
|
|
88
|
-
personality: 'p',
|
|
89
|
-
occasion: 'remove',
|
|
90
|
-
timeoutMs: 300,
|
|
91
|
-
})
|
|
92
|
-
expect(o.state).toBe('timeout')
|
|
93
|
-
expect(o.durationMs).toBeLessThan(5000)
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
test('missing/non-executable command → not-executable (never throws)', () => {
|
|
97
|
-
const o = runProvisionCommand({
|
|
98
|
-
block: { command: join(dir, 'nope.sh'), args: [] },
|
|
99
|
-
cwd: '/x',
|
|
100
|
-
runtime: 'claude',
|
|
101
|
-
personality: 'p',
|
|
102
|
-
occasion: 'birth',
|
|
103
|
-
})
|
|
104
|
-
expect(o.state).toBe('not-executable')
|
|
105
|
-
expect(o.exitCode).toBeNull()
|
|
106
|
-
})
|
|
107
|
-
})
|