@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.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +2 -2
  3. package/package.json +21 -2
  4. package/src/cli/index.ts +47 -48
  5. package/src/connect/index.ts +4 -4
  6. package/src/core/constants.ts +3 -3
  7. package/src/core/normalize.ts +2 -2
  8. package/src/create/index.ts +1 -1
  9. package/src/daemon/index.ts +5 -5
  10. package/src/daemon/main.ts +9 -9
  11. package/src/enable/index.ts +19 -18
  12. package/src/enable/provisionCommand.ts +10 -10
  13. package/src/identity/index.ts +2 -2
  14. package/src/identity/profileStandard.ts +8 -8
  15. package/src/init/index.ts +10 -10
  16. package/src/install/index.ts +2 -2
  17. package/src/install/signing.ts +3 -4
  18. package/src/launch/adapters/claude.ts +7 -7
  19. package/src/launch/adapters/codex.ts +5 -6
  20. package/src/launch/canary.ts +8 -8
  21. package/src/launch/cmdlog.ts +3 -3
  22. package/src/launch/composeSystemPrompt.ts +2 -1
  23. package/src/launch/index.ts +5 -6
  24. package/src/launch/invocation.ts +1 -1
  25. package/src/launch/launchd.ts +14 -15
  26. package/src/launch/launchdRun.ts +6 -6
  27. package/src/launch/nativeMemory.ts +9 -9
  28. package/src/launch/ptyHost.ts +1 -1
  29. package/src/launch/readyGateModel.ts +3 -3
  30. package/src/launch/types.ts +7 -7
  31. package/src/lifecycle/eventlog.ts +2 -2
  32. package/src/lifecycle/index.ts +21 -21
  33. package/src/lifecycle/queue.ts +1 -1
  34. package/src/onboard/index.ts +19 -13
  35. package/src/onboard/memory.ts +5 -5
  36. package/src/onboard/steps.ts +8 -9
  37. package/src/provision/index.ts +2 -3
  38. package/src/runtime/deploy.ts +2 -2
  39. package/src/runtime/index.ts +4 -4
  40. package/src/shadow/gate.ts +6 -6
  41. package/src/shadow/index.ts +2 -2
  42. package/src/status/index.ts +11 -11
  43. package/src/storage/index.ts +1 -1
  44. package/src/supervisor/daemon.ts +14 -8
  45. package/src/transport/index.ts +11 -11
  46. package/src/update/index.ts +4 -4
  47. package/src/cli/cli.test.ts +0 -603
  48. package/src/cli/listTui.test.ts +0 -71
  49. package/src/codec/codec.test.ts +0 -271
  50. package/src/connect/connect.test.ts +0 -163
  51. package/src/core/constants.test.ts +0 -32
  52. package/src/core/normalize.test.ts +0 -98
  53. package/src/create/create.test.ts +0 -143
  54. package/src/daemon/daemon-http.test.ts +0 -114
  55. package/src/daemon/daemon.test.ts +0 -267
  56. package/src/daemon/main.test.ts +0 -413
  57. package/src/enable/enable.test.ts +0 -95
  58. package/src/enable/provisionCommand.test.ts +0 -107
  59. package/src/identity/identity.test.ts +0 -279
  60. package/src/identity/profileStandard.test.ts +0 -204
  61. package/src/init/init.test.ts +0 -215
  62. package/src/init/runtime-resolve.test.ts +0 -49
  63. package/src/install/install.test.ts +0 -66
  64. package/src/install/signing.test.ts +0 -84
  65. package/src/launch/bootdeliver.test.ts +0 -108
  66. package/src/launch/bootstrap.test.ts +0 -56
  67. package/src/launch/canary.test.ts +0 -346
  68. package/src/launch/cmdlog.test.ts +0 -94
  69. package/src/launch/codexHooksTrust.test.ts +0 -248
  70. package/src/launch/composeSystemPrompt.layers.test.ts +0 -445
  71. package/src/launch/composeSystemPrompt.test.ts +0 -145
  72. package/src/launch/hostComposerOccupied.test.ts +0 -63
  73. package/src/launch/invocation.test.ts +0 -174
  74. package/src/launch/launch.test.ts +0 -359
  75. package/src/launch/launchd.test.ts +0 -499
  76. package/src/launch/nativeMemory.test.ts +0 -193
  77. package/src/launch/ptyHost.test.ts +0 -38
  78. package/src/launch/readyGateModel.test.ts +0 -72
  79. package/src/launch/sockdir.test.ts +0 -74
  80. package/src/lifecycle/eventlog.test.ts +0 -114
  81. package/src/lifecycle/lifecycle.test.ts +0 -1056
  82. package/src/lifecycle/queue.test.ts +0 -185
  83. package/src/onboard/memory.test.ts +0 -157
  84. package/src/onboard/onboard.test.ts +0 -39
  85. package/src/onboard/steps.test.ts +0 -201
  86. package/src/provision/provision.test.ts +0 -288
  87. package/src/registry/registry.test.ts +0 -432
  88. package/src/runtime/runtime.test.ts +0 -226
  89. package/src/runtime/update.test.ts +0 -192
  90. package/src/shadow/enumerate-skip.test.ts +0 -67
  91. package/src/shadow/gate.test.ts +0 -286
  92. package/src/shadow/install.test.ts +0 -153
  93. package/src/shadow/shadow.test.ts +0 -42
  94. package/src/status/status.test.ts +0 -208
  95. package/src/storage/peers-home.test.ts +0 -34
  96. package/src/storage/rotatelog.test.ts +0 -44
  97. package/src/storage/storage.test.ts +0 -65
  98. package/src/supervisor/attached-marker.test.ts +0 -84
  99. package/src/supervisor/boot.test.ts +0 -66
  100. package/src/supervisor/client.attach.test.ts +0 -107
  101. package/src/supervisor/daemon.test.ts +0 -213
  102. package/src/supervisor/deliver.test.ts +0 -58
  103. package/src/supervisor/panelog.test.ts +0 -95
  104. package/src/supervisor/protocol.test.ts +0 -222
  105. package/src/supervisor/selfargv.test.ts +0 -22
  106. package/src/supervisor/spinner-strip.test.ts +0 -52
  107. package/src/transport/compactDone.test.ts +0 -144
  108. package/src/transport/composerQueue.test.ts +0 -204
  109. package/src/transport/hostControl.test.ts +0 -87
  110. package/src/transport/hostDeliver.test.ts +0 -137
  111. package/src/transport/hostLive.test.ts +0 -50
  112. package/src/transport/routesend-wake.test.ts +0 -357
  113. package/src/update/update.test.ts +0 -208
@@ -1,499 +0,0 @@
1
- // launchd plist generation + the install↔isLaunchdManaged round-trip + the notifier
2
- // runtime classification. Validates the rendered plist with the REAL macOS
3
- // `plutil -lint` (a live check that the XML is a well-formed plist), exercises XML
4
- // escaping of hostile cwd characters, and proves the generator and the H4 detector
5
- // agree on the label/dir scheme.
6
-
7
- import { afterEach, describe, expect, test } from 'bun:test'
8
- import { spawnSync } from 'child_process'
9
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
10
- import { tmpdir } from 'os'
11
- import { join } from 'path'
12
- import {
13
- bootstrapJobCore,
14
- cycleDaemonCore,
15
- getAdapter,
16
- installAlwaysOnPlist,
17
- isFoundationOwnedPlist,
18
- launchAgentsDir,
19
- launchdLabel,
20
- launchdPlistPath,
21
- renderLaunchdPlist,
22
- resolveExecutable,
23
- type LaunchdPlistSpec,
24
- } from './index.ts'
25
- import { isLaunchdManaged } from '../lifecycle/index.ts'
26
- import { buildAlwaysOnSpec, runAlwaysOn } from './launchdRun.ts'
27
- import { defaultIntelligenceForRuntime, isInfraRuntime } from '../core/constants.ts'
28
-
29
- const tmpDirs: string[] = []
30
- function mkTmp(): string {
31
- const d = mkdtempSync(join(tmpdir(), 'iapeer-launchd-'))
32
- tmpDirs.push(d)
33
- return d
34
- }
35
- afterEach(() => {
36
- while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true })
37
- })
38
-
39
- function plutilLint(path: string): { ok: boolean; out: string } {
40
- const r = spawnSync('plutil', ['-lint', path], { encoding: 'utf8' })
41
- return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() }
42
- }
43
-
44
- const baseSpec: LaunchdPlistSpec = {
45
- label: 'com.iapeer.timer',
46
- programArguments: ['/path/to/bun', '/pkg/src/launch/launchdRun.ts', 'timer', 'notifier'],
47
- workingDirectory: '/Users/x/Peers/timer',
48
- environment: {
49
- PEER_PERSONALITY: 'timer',
50
- PEER_RUNTIME: 'notifier',
51
- PEER_IDENTITY: 'notifier-timer',
52
- PATH: '/usr/bin:/bin',
53
- },
54
- stdoutPath: '/Users/x/Peers/timer/.iapeer/logs/notifier/launchd-stdout.log',
55
- stderrPath: '/Users/x/Peers/timer/.iapeer/logs/notifier/launchd-stderr.log',
56
- }
57
-
58
- describe('runtime classification', () => {
59
- test('notifier → intelligence absent (zone)', () => {
60
- expect(defaultIntelligenceForRuntime('notifier')).toBe('absent')
61
- })
62
- test('isInfraRuntime: notifier + telegram infra; claude/codex not', () => {
63
- expect(isInfraRuntime('notifier')).toBe(true)
64
- expect(isInfraRuntime('telegram')).toBe(true)
65
- expect(isInfraRuntime('claude')).toBe(false)
66
- expect(isInfraRuntime('codex')).toBe(false)
67
- })
68
- test('every infra runtime resolves to a router adapter (isInfraRuntime ↔ getAdapter can not drift)', () => {
69
- for (const rt of ['notifier', 'telegram']) {
70
- expect(isInfraRuntime(rt)).toBe(true)
71
- expect(getAdapter(rt).kind).toBe('router') // always-on bring-up needs the launch-primitive adapter
72
- }
73
- })
74
- })
75
-
76
- describe('launchdLabel / launchAgentsDir', () => {
77
- test('label = com.iapeer.<personality>', () => {
78
- expect(launchdLabel('timer')).toBe('com.iapeer.timer')
79
- expect(launchdLabel('watcher')).toBe('com.iapeer.watcher')
80
- })
81
- test('launchAgentsDir honors IAPEER_LAUNCHAGENTS_DIR', () => {
82
- expect(launchAgentsDir({ IAPEER_LAUNCHAGENTS_DIR: '/tmp/la' })).toBe('/tmp/la')
83
- })
84
- })
85
-
86
- describe('renderLaunchdPlist', () => {
87
- test('valid plist (live plutil -lint) with always-on keys + throttle default 10', () => {
88
- const xml = renderLaunchdPlist(baseSpec)
89
- expect(xml).toContain('<key>Label</key>\n <string>com.iapeer.timer</string>')
90
- expect(xml).toContain('<key>RunAtLoad</key>\n <true/>')
91
- expect(xml).toContain('<key>KeepAlive</key>\n <true/>')
92
- expect(xml).toContain('<key>ThrottleInterval</key>\n <integer>10</integer>')
93
- expect(xml).toContain('<key>PEER_IDENTITY</key>\n <string>notifier-timer</string>')
94
- expect(xml).toContain('<string>/pkg/src/launch/launchdRun.ts</string>')
95
-
96
- const dir = mkTmp()
97
- const f = join(dir, 'r.plist')
98
- writeFileSync(f, xml)
99
- const lint = plutilLint(f)
100
- expect(lint.ok).toBe(true) // plutil parsed it as a valid plist
101
- })
102
-
103
- test('explicit throttle is honored', () => {
104
- expect(renderLaunchdPlist({ ...baseSpec, throttleIntervalSecs: 30 })).toContain(
105
- '<key>ThrottleInterval</key>\n <integer>30</integer>',
106
- )
107
- })
108
-
109
- test('XML-escapes hostile cwd/personality and STAYS plutil-valid', () => {
110
- const hostile = '/Users/x/Peers/a & b <weird>'
111
- const xml = renderLaunchdPlist({ ...baseSpec, workingDirectory: hostile })
112
- expect(xml).toContain('<string>/Users/x/Peers/a &amp; b &lt;weird&gt;</string>')
113
- expect(xml).not.toContain('a & b <weird>') // raw unescaped must not appear
114
- const dir = mkTmp()
115
- const f = join(dir, 'h.plist')
116
- writeFileSync(f, xml)
117
- expect(plutilLint(f).ok).toBe(true)
118
- })
119
-
120
- test('drops XML-1.0-illegal control chars (NUL/C0) and stays valid', () => {
121
- const xml = renderLaunchdPlist({ ...baseSpec, workingDirectory: '/a\x00b\x01c\x1ftab\tkeep' })
122
- expect(xml).toContain('<string>/abctab\tkeep</string>') // controls gone, tab preserved
123
- expect(xml).not.toContain('\x00')
124
- const dir = mkTmp()
125
- const f = join(dir, 'c.plist')
126
- writeFileSync(f, xml)
127
- expect(plutilLint(f).ok).toBe(true)
128
- })
129
- })
130
-
131
- describe('installAlwaysOnPlist ↔ isLaunchdManaged round-trip', () => {
132
- test('installs a valid notifier plist that isLaunchdManaged then detects', () => {
133
- const root = mkTmp()
134
- const laDir = join(root, 'LaunchAgents')
135
- const cwd = join(root, 'peer-timer')
136
- // IAPEER_ROOT keeps the now-GLOBAL infra log dir (~/.iapeer/logs/<p>) under the
137
- // sandbox, not the real home (Фаза §8 moved infra logs out of the per-peer cwd).
138
- const env = { IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
139
-
140
- const path = installAlwaysOnPlist({
141
- personality: 'timer',
142
- runtime: 'notifier',
143
- cwd,
144
- entrypointArgv: ['/path/to/bun', '/pkg/src/launch/launchdRun.ts'],
145
- path: '/usr/bin:/bin',
146
- env,
147
- })
148
-
149
- expect(path).toBe(launchdPlistPath('timer', env))
150
- expect(existsSync(path)).toBe(true)
151
- // generator and H4 detector agree (shared launchdLabel/launchAgentsDir)
152
- expect(isLaunchdManaged('timer', env)).toBe(true)
153
- expect(isLaunchdManaged('nobody', env)).toBe(false)
154
- // the installed file is a valid plist
155
- expect(plutilLint(path).ok).toBe(true)
156
- })
157
-
158
- test('DEFAULT entrypoint (Ф-F) = the installed `iapeer run-infra <p> <r>` binary, not bun src', () => {
159
- const root = mkTmp()
160
- // HOME=/Users/x exercises the default binary path (~/.local/bin/iapeer); IAPEER_ROOT
161
- // keeps the global infra log dir under the sandbox (not the fake /Users/x).
162
- const env = {
163
- IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
164
- HOME: '/Users/x',
165
- IAPEER_ROOT: join(root, 'iapeer'),
166
- } as NodeJS.ProcessEnv
167
- // no entrypointArgv → the default must be the installed binary + run-infra verb
168
- const path = installAlwaysOnPlist({ personality: 'timer', runtime: 'notifier', cwd: join(root, 'c'), env })
169
- const xml = readFileSync(path, 'utf8')
170
- expect(xml).toContain('<string>/Users/x/.local/bin/iapeer</string>')
171
- expect(xml).toContain('<string>run-infra</string>')
172
- expect(xml).toContain('<string>timer</string>')
173
- expect(xml).toContain('<string>notifier</string>')
174
- expect(xml).not.toContain('launchdRun.ts') // decoupled from the src tree
175
- })
176
-
177
- test('refuses a non-infra (warm-on-demand) runtime', () => {
178
- const root = mkTmp()
179
- expect(() =>
180
- installAlwaysOnPlist({
181
- personality: 'boris',
182
- runtime: 'claude',
183
- cwd: join(root, 'p'),
184
- env: { IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents') } as NodeJS.ProcessEnv,
185
- }),
186
- ).toThrow(/not an always-on infra runtime/)
187
- })
188
- })
189
-
190
- // The launchd Label com.iapeer.<personality> is keyed on PERSONALITY and SHARED
191
- // with the live persistent-peer fleet (com.iapeer.arthur, com.iapeer.boris — real
192
- // people/agents, launchd-held RIGHT NOW). A personality collision would point the
193
- // installer at a PP-managed plist. H4 invariant: the foundation must NEVER clobber
194
- // a plist it does not own. The guard tells "ours" from "theirs" by a sentinel the
195
- // foundation renderer embeds — a PP/foreign plist lacks it → refuse, do not write.
196
- describe('installAlwaysOnPlist collision guard (H4 — shared com.iapeer.* namespace)', () => {
197
- // A persistent-peer start.sh plist for `boris` — same com.iapeer.boris Label, but
198
- // NOT foundation-rendered (no sentinel). This stands in for a live PP-managed file.
199
- const foreignPpPlist = `<?xml version="1.0" encoding="UTF-8"?>
200
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
201
- <plist version="1.0">
202
- <dict>
203
- <key>Label</key>
204
- <string>com.iapeer.boris</string>
205
- <key>ProgramArguments</key>
206
- <array>
207
- <string>/Users/x/.iapeer/plugins/persistent-peer/start.sh</string>
208
- <string>boris</string>
209
- </array>
210
- <key>RunAtLoad</key>
211
- <true/>
212
- <key>KeepAlive</key>
213
- <true/>
214
- </dict>
215
- </plist>
216
- `
217
-
218
- test('REFUSES to overwrite a foreign (PP-managed) com.iapeer.* plist — no silent clobber', () => {
219
- const root = mkTmp()
220
- const laDir = join(root, 'LaunchAgents')
221
- mkdirSync(laDir, { recursive: true })
222
- const env = { IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
223
- const path = launchdPlistPath('boris', env)
224
- writeFileSync(path, foreignPpPlist) // a live PP peer's plist already sits here
225
-
226
- expect(() =>
227
- installAlwaysOnPlist({
228
- personality: 'boris',
229
- runtime: 'notifier',
230
- cwd: join(root, 'peer-boris'),
231
- entrypointArgv: ['/path/to/bun', '/pkg/src/launch/launchdRun.ts'],
232
- path: '/usr/bin:/bin',
233
- env,
234
- }),
235
- ).toThrow(/foundation-managed|refus/i)
236
-
237
- // the foreign plist MUST be byte-for-byte untouched (the live PP peer survives)
238
- expect(readFileSync(path, 'utf8')).toBe(foreignPpPlist)
239
- })
240
-
241
- test('isFoundationOwnedPlist: own (rendered) → true, foreign → false, absent → false', () => {
242
- const dir = mkTmp()
243
- const own = join(dir, 'own.plist')
244
- writeFileSync(own, renderLaunchdPlist(baseSpec)) // foundation renderer → sentinel
245
- expect(isFoundationOwnedPlist(own)).toBe(true)
246
-
247
- const foreign = join(dir, 'foreign.plist')
248
- writeFileSync(foreign, foreignPpPlist) // PP start.sh plist → no sentinel
249
- expect(isFoundationOwnedPlist(foreign)).toBe(false)
250
-
251
- expect(isFoundationOwnedPlist(join(dir, 'nope.plist'))).toBe(false) // absent
252
- })
253
-
254
- test('re-installing the foundation OWN plist is idempotent (sentinel present → allowed)', () => {
255
- const root = mkTmp()
256
- const laDir = join(root, 'LaunchAgents')
257
- const env = { IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
258
- const opts = {
259
- personality: 'timer',
260
- runtime: 'notifier' as const,
261
- cwd: join(root, 'peer-timer'),
262
- entrypointArgv: ['/path/to/bun', '/pkg/src/launch/launchdRun.ts'],
263
- path: '/usr/bin:/bin',
264
- env,
265
- }
266
- const path = installAlwaysOnPlist(opts) // first install — writes OUR plist
267
- expect(isFoundationOwnedPlist(path)).toBe(true)
268
- // second install over our own plist must NOT throw (idempotent re-provision)
269
- expect(() => installAlwaysOnPlist(opts)).not.toThrow()
270
- expect(plutilLint(path).ok).toBe(true)
271
- })
272
- })
273
-
274
- // launchd gives a job a MINIMAL PATH (no ~/.local/bin, ~/.bun/bin), so an infra
275
- // peer's always-on plist must PIN its launcher to an absolute path or the session
276
- // crash-loops. installAlwaysOnPlist resolves the bin against the rich provisioning
277
- // PATH and bakes NOTIFIER_RUNTIME_BIN / TELEGRAM_RUNTIME_BIN.
278
- describe('resolveExecutable + runtime-bin pinning', () => {
279
- function fakeExec(dir: string, name: string): string {
280
- const p = join(dir, name)
281
- writeFileSync(p, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
282
- return p
283
- }
284
-
285
- test('resolveExecutable: finds on PATH, abs passthrough, undefined when absent', () => {
286
- const dir = mkTmp()
287
- const bin = fakeExec(dir, 'notifier-runtime')
288
- expect(resolveExecutable('notifier-runtime', { PATH: dir } as NodeJS.ProcessEnv)).toBe(bin)
289
- expect(resolveExecutable(bin, {} as NodeJS.ProcessEnv)).toBe(bin) // abs path, exists+exec
290
- expect(resolveExecutable('no-such-bin', { PATH: dir } as NodeJS.ProcessEnv)).toBeUndefined()
291
- expect(resolveExecutable('/nope/notifier-runtime', {} as NodeJS.ProcessEnv)).toBeUndefined()
292
- })
293
-
294
- test('explicit runtimeBin (abs) is baked into the plist env', () => {
295
- const root = mkTmp()
296
- const env = { IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'), IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
297
- const path = installAlwaysOnPlist({
298
- personality: 'timer', runtime: 'notifier', cwd: join(root, 'p'),
299
- runtimeBin: '/opt/iapeer/notifier-runtime',
300
- entrypointArgv: ['/bun', '/run.ts'], path: '/usr/bin:/bin', env,
301
- })
302
- const xml = readFileSync(path, 'utf8')
303
- expect(xml).toContain('<key>NOTIFIER_RUNTIME_BIN</key>')
304
- expect(xml).toContain('<string>/opt/iapeer/notifier-runtime</string>')
305
- expect(plutilLint(path).ok).toBe(true)
306
- })
307
-
308
- test('runtimeBin omitted → default bin resolved from env.PATH and pinned', () => {
309
- const root = mkTmp()
310
- const bindir = mkTmp()
311
- const bin = fakeExec(bindir, 'notifier-runtime')
312
- const env = { IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'), PATH: bindir, IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
313
- const path = installAlwaysOnPlist({
314
- personality: 'timer', runtime: 'notifier', cwd: join(root, 'p'),
315
- entrypointArgv: ['/bun', '/run.ts'], env,
316
- })
317
- expect(readFileSync(path, 'utf8')).toContain(`<string>${bin}</string>`)
318
- })
319
-
320
- test('unresolvable launcher → NOT baked (no crash; bare name + plist PATH remain)', () => {
321
- const root = mkTmp()
322
- const env = { IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'), PATH: join(root, 'empty'), IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
323
- const path = installAlwaysOnPlist({
324
- personality: 'timer', runtime: 'notifier', cwd: join(root, 'p'),
325
- entrypointArgv: ['/bun', '/run.ts'], path: '/usr/bin:/bin', env,
326
- })
327
- expect(readFileSync(path, 'utf8')).not.toContain('NOTIFIER_RUNTIME_BIN')
328
- })
329
- })
330
-
331
- // ─────────────────────────────────────────────────────────────────────────────
332
- // bootstrapJobCore — the undead-job-safe bootstrap (boris's connect-acceptance
333
- // find 10.06: bootout → immediate bootstrap → exit 5 I/O error → the whole
334
- // fleet's telegram router stayed DOWN). Pure DI core: run/sleep injected.
335
- // ─────────────────────────────────────────────────────────────────────────────
336
-
337
- describe('bootstrapJobCore (undead-job race)', () => {
338
- type Call = { args: string[] }
339
- function harness(script: { printStatuses: number[]; bootstrapStatuses: number[] }) {
340
- const calls: Call[] = []
341
- const sleeps: number[] = []
342
- let printI = 0
343
- let bootI = 0
344
- const run = (args: string[]) => {
345
- calls.push({ args })
346
- if (args[0] === 'print') {
347
- const status = script.printStatuses[Math.min(printI, script.printStatuses.length - 1)]!
348
- printI++
349
- return { status, stderr: '' }
350
- }
351
- const status = script.bootstrapStatuses[Math.min(bootI, script.bootstrapStatuses.length - 1)]!
352
- bootI++
353
- return { status, stderr: status === 0 ? '' : 'Bootstrap failed: 5: Input/output error' }
354
- }
355
- return { calls, sleeps, deps: { run, sleepMs: (ms: number) => void sleeps.push(ms) } }
356
- }
357
-
358
- test('clean path: job not listed, first bootstrap succeeds — zero sleeps', () => {
359
- const h = harness({ printStatuses: [1], bootstrapStatuses: [0] })
360
- const r = bootstrapJobCore('501', 'com.iapeer.x', '/p.plist', h.deps)
361
- expect(r).toEqual({ state: 'loaded', attempts: 1 })
362
- expect(h.sleeps).toEqual([])
363
- })
364
-
365
- test("boris's repro: undead job vanishes after polls, first bootstrap exit 5, retry succeeds", () => {
366
- // print: listed, listed, gone (the bootout dismantle window) → bootstrap:
367
- // exit 5 once (still racy), success on the retry after backoff.
368
- const h = harness({ printStatuses: [0, 0, 1, 1, 1], bootstrapStatuses: [5, 0] })
369
- const r = bootstrapJobCore('501', 'com.iapeer.arthur', '/p.plist', h.deps)
370
- expect(r.state).toBe('loaded')
371
- expect(r.attempts).toBe(2)
372
- expect(h.sleeps.length).toBeGreaterThan(0) // waited for gone + backoff before retry
373
- })
374
-
375
- test('genuinely LIVE job (stays listed through the gone budget) → already-loaded, bootstrap NEVER called', () => {
376
- const h = harness({ printStatuses: [0], bootstrapStatuses: [0] }) // always listed
377
- const r = bootstrapJobCore('501', 'com.iapeer.x', '/p.plist', { ...h.deps, goneTimeoutMs: 2_000 })
378
- expect(r).toEqual({ state: 'already-loaded', attempts: 0 })
379
- expect(h.calls.some(c => c.args[0] === 'bootstrap')).toBe(false)
380
- })
381
-
382
- test('every attempt fails → failed with the attempt count and the last stderr (LOUD, not silent)', () => {
383
- const h = harness({ printStatuses: [1], bootstrapStatuses: [5] })
384
- const r = bootstrapJobCore('501', 'com.iapeer.x', '/p.plist', h.deps)
385
- expect(r.state).toBe('failed')
386
- expect(r.attempts).toBe(4)
387
- expect(r.detail).toContain('Input/output error')
388
- expect(r.detail).toContain('4 bootstrap attempts')
389
- })
390
-
391
- test('a racing load between attempts reads already-loaded (idempotent success)', () => {
392
- // first bootstrap fails; before the retry the job shows up listed (raced in)
393
- const h = harness({ printStatuses: [1, 0], bootstrapStatuses: [5] })
394
- const r = bootstrapJobCore('501', 'com.iapeer.x', '/p.plist', h.deps)
395
- expect(r.state).toBe('already-loaded')
396
- })
397
- })
398
-
399
- describe('runAlwaysOn guard', () => {
400
- test('a non-infra runtime is rejected with exit code 1 (no tmux touched)', async () => {
401
- expect(await runAlwaysOn('boris', 'claude', '/tmp/whatever')).toBe(1)
402
- })
403
- })
404
-
405
- // REGRESSION (Ф-A #3 adversarial-verify find): the always-on launch path MUST carry
406
- // the peer's intelligence onto the spec, or the launch primitive's telegram nature
407
- // gate (requires natural) fails `natural !== undefined` → exit 1 → launchd KeepAlive
408
- // crash-loop on EVERY telegram bot. buildAlwaysOnSpec reads it from the local profile.
409
- describe('buildAlwaysOnSpec carries intelligence (telegram crash-loop guard)', () => {
410
- test('a provisioned telegram peer (intelligence=natural) → spec.intelligence=natural (clears the gate)', () => {
411
- const cwd = mkdtempSync(join(tmpdir(), 'iapeer-alwayson-'))
412
- mkdirSync(join(cwd, '.iapeer'), { recursive: true })
413
- writeFileSync(
414
- join(cwd, '.iapeer', 'peer-profile.json'),
415
- JSON.stringify({ personality: 'mybot', runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'natural' }),
416
- )
417
- const spec = buildAlwaysOnSpec('mybot', 'telegram', cwd, '/tmp')
418
- expect(spec.intelligence).toBe('natural')
419
- // and the gate it feeds would pass: telegram + natural is not refused
420
- rmSync(cwd, { recursive: true, force: true })
421
- })
422
-
423
- test('legacy human profile self-heals to natural on read → still clears the gate', () => {
424
- const cwd = mkdtempSync(join(tmpdir(), 'iapeer-alwayson-'))
425
- mkdirSync(join(cwd, '.iapeer'), { recursive: true })
426
- writeFileSync(
427
- join(cwd, '.iapeer', 'peer-profile.json'),
428
- JSON.stringify({ personality: 'mybot', runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'human' }),
429
- )
430
- expect(buildAlwaysOnSpec('mybot', 'telegram', cwd, '/tmp').intelligence).toBe('natural')
431
- rmSync(cwd, { recursive: true, force: true })
432
- })
433
- })
434
-
435
- // ─── cycleDaemonCore — the LWCR-safe deploy restart (bootout+bootstrap) ───────
436
- // Proven live 12.06 twice: a kickstart after a binary replacement respawns into
437
- // an EX_CONFIG(78) crash-loop when launchd's managed Launch Constraint still
438
- // pins the OLD binary's signature (com.iapeer.arthur, then com.agfpd.iapeer
439
- // itself on the 0.2.43 deploy). The cure — and now the ONLY restart form the
440
- // update pipeline uses — is a full re-registration.
441
-
442
- describe('cycleDaemonCore (LWCR-safe bootout+bootstrap)', () => {
443
- function harness(script: { printStatuses: number[]; bootoutStatus?: number; bootstrapStatuses?: number[] }) {
444
- const calls: string[][] = []
445
- let printI = 0
446
- let bootI = 0
447
- const run = (args: string[]) => {
448
- calls.push(args)
449
- if (args[0] === 'print') {
450
- const status = script.printStatuses[Math.min(printI, script.printStatuses.length - 1)]!
451
- printI++
452
- return { status, stderr: '' }
453
- }
454
- if (args[0] === 'bootout') return { status: script.bootoutStatus ?? 0, stderr: script.bootoutStatus ? 'boom' : '' }
455
- const status = (script.bootstrapStatuses ?? [0])[Math.min(bootI, (script.bootstrapStatuses ?? [0]).length - 1)]!
456
- bootI++
457
- return { status, stderr: status === 0 ? '' : 'Bootstrap failed' }
458
- }
459
- return { calls, deps: { run, sleepMs: () => {} } }
460
- }
461
-
462
- test('loaded job: bootout → gone → bootstrap → restarted', () => {
463
- // print: loaded (pre-check) → gone (core wait) → bootstrap ok
464
- const h = harness({ printStatuses: [0, 1], bootstrapStatuses: [0] })
465
- expect(cycleDaemonCore('501', '/p.plist', h.deps)).toEqual({ state: 'restarted' })
466
- expect(h.calls.some(c => c[0] === 'bootout')).toBe(true)
467
- expect(h.calls.some(c => c[0] === 'bootstrap')).toBe(true)
468
- })
469
-
470
- test('not loaded → not-loaded, NOTHING booted out', () => {
471
- const h = harness({ printStatuses: [1] })
472
- expect(cycleDaemonCore('501', '/p.plist', h.deps)).toEqual({ state: 'not-loaded' })
473
- expect(h.calls.some(c => c[0] === 'bootout')).toBe(false)
474
- })
475
-
476
- test('bootout fails → failed loud, no bootstrap attempt', () => {
477
- const h = harness({ printStatuses: [0], bootoutStatus: 9 })
478
- const r = cycleDaemonCore('501', '/p.plist', h.deps)
479
- expect(r.state).toBe('failed')
480
- expect(r.detail).toContain('bootout failed')
481
- expect(h.calls.some(c => c[0] === 'bootstrap')).toBe(false)
482
- })
483
-
484
- test('job never unloads after bootout (undead beyond budget) → failed with the manual recipe', () => {
485
- // print: loaded (pre-check), then loaded FOREVER (the core wait + retries see it listed)
486
- const h = harness({ printStatuses: [0, 0] })
487
- const r = cycleDaemonCore('501', '/p.plist', h.deps)
488
- expect(r.state).toBe('failed')
489
- expect(r.detail).toContain('manual rescue')
490
- })
491
-
492
- test('bootstrap retries exhausted → failed with rescue hint', () => {
493
- // pre-check loaded → gone for the wait → every bootstrap attempt fails, re-checks stay gone
494
- const h = harness({ printStatuses: [0, 1], bootstrapStatuses: [5, 5, 5, 5] })
495
- const r = cycleDaemonCore('501', '/p.plist', h.deps)
496
- expect(r.state).toBe('failed')
497
- expect(r.detail).toContain('manual rescue')
498
- })
499
- })