@agfpd/iapeer 0.1.0

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 (63) hide show
  1. package/bin/iapeer +25 -0
  2. package/package.json +37 -0
  3. package/src/cli/cli.test.ts +130 -0
  4. package/src/cli/index.ts +608 -0
  5. package/src/cli/listTui.test.ts +70 -0
  6. package/src/cli/listTui.ts +165 -0
  7. package/src/codec/codec.test.ts +271 -0
  8. package/src/codec/index.ts +217 -0
  9. package/src/core/constants.test.ts +21 -0
  10. package/src/core/constants.ts +180 -0
  11. package/src/core/errors.ts +20 -0
  12. package/src/core/index.ts +3 -0
  13. package/src/core/normalize.test.ts +98 -0
  14. package/src/core/normalize.ts +89 -0
  15. package/src/core/socket.ts +63 -0
  16. package/src/create/create.test.ts +143 -0
  17. package/src/create/index.ts +178 -0
  18. package/src/daemon/daemon-http.test.ts +114 -0
  19. package/src/daemon/daemon.test.ts +103 -0
  20. package/src/daemon/index.ts +439 -0
  21. package/src/daemon/main.test.ts +194 -0
  22. package/src/daemon/main.ts +230 -0
  23. package/src/enable/enable.test.ts +92 -0
  24. package/src/enable/index.ts +381 -0
  25. package/src/identity/identity.test.ts +262 -0
  26. package/src/identity/index.ts +603 -0
  27. package/src/index.ts +27 -0
  28. package/src/init/index.ts +408 -0
  29. package/src/init/init.test.ts +171 -0
  30. package/src/init/runtime-resolve.test.ts +49 -0
  31. package/src/install/index.ts +84 -0
  32. package/src/install/install.test.ts +31 -0
  33. package/src/launch/adapters/claude.ts +250 -0
  34. package/src/launch/adapters/codex.ts +329 -0
  35. package/src/launch/adapters/notifier.ts +90 -0
  36. package/src/launch/adapters/telegram.ts +130 -0
  37. package/src/launch/bootstrap.test.ts +56 -0
  38. package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
  39. package/src/launch/composeSystemPrompt.test.ts +98 -0
  40. package/src/launch/composeSystemPrompt.ts +261 -0
  41. package/src/launch/index.ts +253 -0
  42. package/src/launch/launch.test.ts +233 -0
  43. package/src/launch/launchd.test.ts +363 -0
  44. package/src/launch/launchd.ts +375 -0
  45. package/src/launch/launchdRun.ts +168 -0
  46. package/src/launch/sockdir.test.ts +70 -0
  47. package/src/launch/types.ts +300 -0
  48. package/src/lifecycle/index.ts +840 -0
  49. package/src/lifecycle/lifecycle.test.ts +496 -0
  50. package/src/onboard/index.ts +135 -0
  51. package/src/onboard/onboard.test.ts +39 -0
  52. package/src/provision/index.ts +170 -0
  53. package/src/provision/provision.test.ts +104 -0
  54. package/src/registry/index.ts +453 -0
  55. package/src/registry/registry.test.ts +400 -0
  56. package/src/runtime/deploy.ts +230 -0
  57. package/src/runtime/index.ts +191 -0
  58. package/src/runtime/runtime.test.ts +226 -0
  59. package/src/storage/index.ts +331 -0
  60. package/src/storage/peers-home.test.ts +34 -0
  61. package/src/storage/storage.test.ts +65 -0
  62. package/src/transport/index.ts +522 -0
  63. package/tsconfig.json +17 -0
@@ -0,0 +1,233 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { getAdapter, launch } from './index.ts'
3
+ import { claudeAdapter } from './adapters/claude.ts'
4
+ import { codexAdapter } from './adapters/codex.ts'
5
+ import { telegramAdapter } from './adapters/telegram.ts'
6
+ import { notifierAdapter } from './adapters/notifier.ts'
7
+ import type { LaunchAdapterConfig, LaunchConfig, LaunchSpec } from './types.ts'
8
+
9
+ const cfg: LaunchAdapterConfig = { claudeBin: '/bin/claude', codexBin: 'codex' }
10
+ // Full LaunchConfig for the gate tests — the intelligence gate returns FAILED at
11
+ // step 0, BEFORE any tmux/FS work, so these values are never exercised.
12
+ const launchCfg: LaunchConfig = {
13
+ claudeBin: '/bin/claude',
14
+ codexBin: 'codex',
15
+ sockDir: '/tmp',
16
+ bootDeadlineSecs: 1,
17
+ readyGateSecs: 1,
18
+ maxAgeSecs: 1,
19
+ logDir: '/tmp/iapeer-test-logs',
20
+ }
21
+ function spec(over: Partial<LaunchSpec> = {}): LaunchSpec {
22
+ return {
23
+ personality: 'p',
24
+ runtime: 'claude',
25
+ cwd: '/tmp/p',
26
+ identity: 'claude-p',
27
+ socketPath: '/tmp/tmux-iap-claude-p.sock',
28
+ ...over,
29
+ }
30
+ }
31
+
32
+ describe('getAdapter', () => {
33
+ test('dispatches each runtime to its adapter', () => {
34
+ expect(getAdapter('claude')).toBe(claudeAdapter)
35
+ expect(getAdapter('codex')).toBe(codexAdapter)
36
+ expect(getAdapter('telegram')).toBe(telegramAdapter)
37
+ expect(getAdapter('notifier')).toBe(notifierAdapter)
38
+ })
39
+ test('codex is tui+doctrine, telegram is router+no-doctrine', () => {
40
+ expect(codexAdapter.kind).toBe('tui')
41
+ expect(codexAdapter.usesDoctrine).toBe(true)
42
+ expect(telegramAdapter.kind).toBe('router')
43
+ expect(telegramAdapter.usesDoctrine).toBe(false)
44
+ })
45
+ test('unknown runtime throws', () => {
46
+ expect(() => getAdapter('webhook')).toThrow()
47
+ })
48
+ })
49
+
50
+ describe('codexAdapter.buildArgv', () => {
51
+ test('bare (no resume, no doctrine): --no-alt-screen -C cwd --dangerously-bypass', () => {
52
+ expect(codexAdapter.buildArgv(spec({ runtime: 'codex', cwd: '/w' }), cfg)).toEqual([
53
+ 'codex',
54
+ '--no-alt-screen',
55
+ '-C',
56
+ '/w',
57
+ '--dangerously-bypass-approvals-and-sandbox',
58
+ ])
59
+ })
60
+ test('with resume + model_instructions_file (exact order)', () => {
61
+ expect(
62
+ codexAdapter.buildArgv(spec({ runtime: 'codex', cwd: '/w', resume: true, systemPromptFile: '/sp.md' }), cfg),
63
+ ).toEqual([
64
+ 'codex',
65
+ 'resume',
66
+ '--last',
67
+ '--no-alt-screen',
68
+ '-C',
69
+ '/w',
70
+ '-c',
71
+ 'model_instructions_file=/sp.md',
72
+ '--dangerously-bypass-approvals-and-sandbox',
73
+ ])
74
+ })
75
+ })
76
+
77
+ describe('telegramAdapter (router — no TUI surface)', () => {
78
+ test('router predicates are trivial', () => {
79
+ expect(telegramAdapter.bootDialogKeys('anything')).toBeNull()
80
+ expect(telegramAdapter.isInputReady('anything')).toBe(true)
81
+ expect(telegramAdapter.newestActivityMtime('/w')).toBeNull()
82
+ expect(telegramAdapter.permissionDialogActive('x')).toBe(false)
83
+ })
84
+ })
85
+
86
+ describe('notifierAdapter (router — infra/always-on)', () => {
87
+ test('kind:router, no doctrine', () => {
88
+ expect(notifierAdapter.runtime).toBe('notifier')
89
+ expect(notifierAdapter.kind).toBe('router')
90
+ expect(notifierAdapter.usesDoctrine).toBe(false)
91
+ })
92
+ test('buildArgv = notifier-runtime run [+extra], default bin', () => {
93
+ expect(notifierAdapter.buildArgv(spec({ runtime: 'notifier' }), cfg)).toEqual(['notifier-runtime', 'run'])
94
+ expect(
95
+ notifierAdapter.buildArgv(spec({ runtime: 'notifier', extraArgs: ['--foo'] }), { ...cfg, notifierBin: '/n/bin' }),
96
+ ).toEqual(['/n/bin', 'run', '--foo'])
97
+ })
98
+ test('router predicates are trivial (no TUI surface)', () => {
99
+ expect(notifierAdapter.bootDialogKeys('anything')).toBeNull()
100
+ expect(notifierAdapter.isInputReady('anything')).toBe(true)
101
+ expect(notifierAdapter.newestActivityMtime('/w')).toBeNull()
102
+ expect(notifierAdapter.permissionDialogActive('x')).toBe(false)
103
+ expect(notifierAdapter.permissionDialogKeys()).toEqual([])
104
+ expect(notifierAdapter.resolveResume('/w')).toEqual({ ok: true })
105
+ })
106
+ })
107
+
108
+ describe('claudeAdapter', () => {
109
+ test('kind/usesDoctrine', () => {
110
+ expect(claudeAdapter.kind).toBe('tui')
111
+ expect(claudeAdapter.usesDoctrine).toBe(true)
112
+ })
113
+
114
+ test('buildArgv bare (no system-prompt, no resume)', () => {
115
+ expect(claudeAdapter.buildArgv(spec(), cfg)).toEqual([
116
+ '/bin/claude',
117
+ '--dangerously-skip-permissions',
118
+ '--disallowedTools',
119
+ 'AskUserQuestion',
120
+ ])
121
+ })
122
+
123
+ test('buildArgv with system-prompt-file + resume + extras (exact flags/order)', () => {
124
+ const argv = claudeAdapter.buildArgv(
125
+ spec({ systemPromptFile: '/tmp/sp.md', resumeRef: 'uuid-1', extraArgs: ['--foo'] }),
126
+ cfg,
127
+ )
128
+ expect(argv).toEqual([
129
+ '/bin/claude',
130
+ '--dangerously-skip-permissions',
131
+ '--disallowedTools',
132
+ 'AskUserQuestion',
133
+ '--system-prompt-file',
134
+ '/tmp/sp.md',
135
+ '--resume',
136
+ 'uuid-1',
137
+ '--foo',
138
+ ])
139
+ })
140
+
141
+ test('buildArgv carries NO currency (no marketplace/plugin tokens)', () => {
142
+ const flat = claudeAdapter.buildArgv(spec({ systemPromptFile: '/x' }), cfg).join(' ')
143
+ expect(flat).not.toMatch(/marketplace|plugin (install|update|marketplace)/)
144
+ })
145
+
146
+ test('isInputReady: ❯ + bypass banner, dialogs gone → true', () => {
147
+ expect(claudeAdapter.isInputReady('… ❯ …\nbypass permissions on')).toBe(true)
148
+ })
149
+ test('isInputReady: a boot dialog present → false even with ❯', () => {
150
+ expect(claudeAdapter.isInputReady('trust this folder\n❯ bypass permissions on')).toBe(false)
151
+ })
152
+ test('isInputReady: no bypass banner → false', () => {
153
+ expect(claudeAdapter.isInputReady('❯ just a prompt')).toBe(false)
154
+ })
155
+
156
+ test('bootDialogKeys: a dialog → [Enter], clean pane → null', () => {
157
+ expect(claudeAdapter.bootDialogKeys('I am using this for local development')).toEqual(['Enter'])
158
+ expect(claudeAdapter.bootDialogKeys('Resuming the full session')).toEqual(['Enter'])
159
+ expect(claudeAdapter.bootDialogKeys('❯ ready')).toBeNull()
160
+ })
161
+
162
+ test('permissionDialog: proceed prompt → active, [Enter]', () => {
163
+ expect(claudeAdapter.permissionDialogActive('Do you want to proceed?')).toBe(true)
164
+ expect(claudeAdapter.permissionDialogActive('nothing')).toBe(false)
165
+ expect(claudeAdapter.permissionDialogKeys()).toEqual(['Enter'])
166
+ })
167
+ })
168
+
169
+ // ─── Ф-A #2: deliveryMarkers OWNED by the adapter (07.06 refactor) ───────────
170
+ describe('deliveryMarkers (adapter-owned, was transport PROMPT_GLYPHS)', () => {
171
+ test('claude: ❯ glyph + paste patterns', () => {
172
+ expect(claudeAdapter.deliveryMarkers.promptGlyphs).toEqual(['❯'])
173
+ expect(claudeAdapter.deliveryMarkers.pastePatterns?.some(re => re.test('[Pasted text +5 lines]'))).toBe(true)
174
+ })
175
+ test('codex: › glyph (not ❯) — per-runtime, no false cross-match', () => {
176
+ expect(codexAdapter.deliveryMarkers.promptGlyphs).toEqual(['›'])
177
+ expect(codexAdapter.deliveryMarkers.promptGlyphs).not.toContain('❯')
178
+ })
179
+ test('routers have no submit surface → empty glyphs', () => {
180
+ expect(telegramAdapter.deliveryMarkers.promptGlyphs).toEqual([])
181
+ expect(notifierAdapter.deliveryMarkers.promptGlyphs).toEqual([])
182
+ })
183
+ })
184
+
185
+ // ─── Ф-A #3: intelligence gate (telegram launch requires natural) ────────────
186
+ describe('launch intelligence gate (adapter.requiresIntelligence)', () => {
187
+ test('telegram declares requiresIntelligence=natural; tui runtimes declare none', () => {
188
+ expect(telegramAdapter.requiresIntelligence).toBe('natural')
189
+ expect(claudeAdapter.requiresIntelligence).toBeUndefined()
190
+ expect(codexAdapter.requiresIntelligence).toBeUndefined()
191
+ expect(notifierAdapter.requiresIntelligence).toBeUndefined()
192
+ })
193
+
194
+ test('launch REFUSES a non-natural peer on telegram (fail-loud, before any tmux)', async () => {
195
+ const r = await launch(
196
+ spec({ runtime: 'telegram', identity: 'telegram-bot', socketPath: '/tmp/tmux-iap-telegram-bot.sock', intelligence: 'artificial' }),
197
+ telegramAdapter,
198
+ 'first',
199
+ launchCfg,
200
+ )
201
+ expect(r.status).toBe('FAILED')
202
+ expect(r.reason).toMatch(/requires intelligence=natural/)
203
+ })
204
+
205
+ test('launch REFUSES when intelligence is unknown (cannot confirm natural)', async () => {
206
+ const r = await launch(
207
+ spec({ runtime: 'telegram', identity: 'telegram-bot', socketPath: '/tmp/tmux-iap-telegram-bot.sock' }),
208
+ telegramAdapter,
209
+ 'first',
210
+ launchCfg,
211
+ )
212
+ expect(r.status).toBe('FAILED')
213
+ expect(r.reason).toMatch(/unknown/)
214
+ })
215
+ })
216
+
217
+ // ─── Ф-E #control: executeControl (adapter-owned in-session control mapping) ──
218
+ describe('executeControl (Ф-E control commands)', () => {
219
+ test('claude: interrupt → [Escape]; compact → type /compact then Enter', () => {
220
+ expect(claudeAdapter.executeControl({ name: 'interrupt' })).toEqual({ sequence: [['Escape']] })
221
+ expect(claudeAdapter.executeControl({ name: 'compact' })).toEqual({ sequence: [['-l', '/compact'], ['Enter']], stepDelayMs: 300 })
222
+ expect(claudeAdapter.executeControl({ name: 'bogus' })).toBeNull()
223
+ })
224
+ test('codex: interrupt → [Escape] (×1 baseline; ×2 snapped live); compact → null', () => {
225
+ expect(codexAdapter.executeControl({ name: 'interrupt' })).toEqual({ sequence: [['Escape']] })
226
+ expect(codexAdapter.executeControl({ name: 'compact' })).toBeNull()
227
+ })
228
+ test('routers (telegram/notifier) refuse all control (no TUI turn)', () => {
229
+ expect(telegramAdapter.executeControl({ name: 'interrupt' })).toBeNull()
230
+ expect(notifierAdapter.executeControl({ name: 'interrupt' })).toBeNull()
231
+ expect(telegramAdapter.executeControl({ name: 'compact' })).toBeNull()
232
+ })
233
+ })
@@ -0,0 +1,363 @@
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
+ getAdapter,
14
+ installAlwaysOnPlist,
15
+ isFoundationOwnedPlist,
16
+ launchAgentsDir,
17
+ launchdLabel,
18
+ launchdPlistPath,
19
+ renderLaunchdPlist,
20
+ resolveExecutable,
21
+ type LaunchdPlistSpec,
22
+ } from './index.ts'
23
+ import { isLaunchdManaged } from '../lifecycle/index.ts'
24
+ import { buildAlwaysOnSpec, runAlwaysOn } from './launchdRun.ts'
25
+ import { defaultIntelligenceForRuntime, isInfraRuntime } from '../core/constants.ts'
26
+
27
+ const tmpDirs: string[] = []
28
+ function mkTmp(): string {
29
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-launchd-'))
30
+ tmpDirs.push(d)
31
+ return d
32
+ }
33
+ afterEach(() => {
34
+ while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true })
35
+ })
36
+
37
+ function plutilLint(path: string): { ok: boolean; out: string } {
38
+ const r = spawnSync('plutil', ['-lint', path], { encoding: 'utf8' })
39
+ return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() }
40
+ }
41
+
42
+ const baseSpec: LaunchdPlistSpec = {
43
+ label: 'com.iapeer.timer',
44
+ programArguments: ['/path/to/bun', '/pkg/src/launch/launchdRun.ts', 'timer', 'notifier'],
45
+ workingDirectory: '/Users/x/Peers/timer',
46
+ environment: {
47
+ PEER_PERSONALITY: 'timer',
48
+ PEER_RUNTIME: 'notifier',
49
+ PEER_IDENTITY: 'notifier-timer',
50
+ PATH: '/usr/bin:/bin',
51
+ },
52
+ stdoutPath: '/Users/x/Peers/timer/.iapeer/logs/notifier/launchd-stdout.log',
53
+ stderrPath: '/Users/x/Peers/timer/.iapeer/logs/notifier/launchd-stderr.log',
54
+ }
55
+
56
+ describe('runtime classification', () => {
57
+ test('notifier → intelligence absent (zone)', () => {
58
+ expect(defaultIntelligenceForRuntime('notifier')).toBe('absent')
59
+ })
60
+ test('isInfraRuntime: notifier + telegram infra; claude/codex not', () => {
61
+ expect(isInfraRuntime('notifier')).toBe(true)
62
+ expect(isInfraRuntime('telegram')).toBe(true)
63
+ expect(isInfraRuntime('claude')).toBe(false)
64
+ expect(isInfraRuntime('codex')).toBe(false)
65
+ })
66
+ test('every infra runtime resolves to a router adapter (isInfraRuntime ↔ getAdapter can not drift)', () => {
67
+ for (const rt of ['notifier', 'telegram']) {
68
+ expect(isInfraRuntime(rt)).toBe(true)
69
+ expect(getAdapter(rt).kind).toBe('router') // always-on bring-up needs the launch-primitive adapter
70
+ }
71
+ })
72
+ })
73
+
74
+ describe('launchdLabel / launchAgentsDir', () => {
75
+ test('label = com.iapeer.<personality>', () => {
76
+ expect(launchdLabel('timer')).toBe('com.iapeer.timer')
77
+ expect(launchdLabel('watcher')).toBe('com.iapeer.watcher')
78
+ })
79
+ test('launchAgentsDir honors IAPEER_LAUNCHAGENTS_DIR', () => {
80
+ expect(launchAgentsDir({ IAPEER_LAUNCHAGENTS_DIR: '/tmp/la' })).toBe('/tmp/la')
81
+ })
82
+ })
83
+
84
+ describe('renderLaunchdPlist', () => {
85
+ test('valid plist (live plutil -lint) with always-on keys + throttle default 10', () => {
86
+ const xml = renderLaunchdPlist(baseSpec)
87
+ expect(xml).toContain('<key>Label</key>\n <string>com.iapeer.timer</string>')
88
+ expect(xml).toContain('<key>RunAtLoad</key>\n <true/>')
89
+ expect(xml).toContain('<key>KeepAlive</key>\n <true/>')
90
+ expect(xml).toContain('<key>ThrottleInterval</key>\n <integer>10</integer>')
91
+ expect(xml).toContain('<key>PEER_IDENTITY</key>\n <string>notifier-timer</string>')
92
+ expect(xml).toContain('<string>/pkg/src/launch/launchdRun.ts</string>')
93
+
94
+ const dir = mkTmp()
95
+ const f = join(dir, 'r.plist')
96
+ writeFileSync(f, xml)
97
+ const lint = plutilLint(f)
98
+ expect(lint.ok).toBe(true) // plutil parsed it as a valid plist
99
+ })
100
+
101
+ test('explicit throttle is honored', () => {
102
+ expect(renderLaunchdPlist({ ...baseSpec, throttleIntervalSecs: 30 })).toContain(
103
+ '<key>ThrottleInterval</key>\n <integer>30</integer>',
104
+ )
105
+ })
106
+
107
+ test('XML-escapes hostile cwd/personality and STAYS plutil-valid', () => {
108
+ const hostile = '/Users/x/Peers/a & b <weird>'
109
+ const xml = renderLaunchdPlist({ ...baseSpec, workingDirectory: hostile })
110
+ expect(xml).toContain('<string>/Users/x/Peers/a &amp; b &lt;weird&gt;</string>')
111
+ expect(xml).not.toContain('a & b <weird>') // raw unescaped must not appear
112
+ const dir = mkTmp()
113
+ const f = join(dir, 'h.plist')
114
+ writeFileSync(f, xml)
115
+ expect(plutilLint(f).ok).toBe(true)
116
+ })
117
+
118
+ test('drops XML-1.0-illegal control chars (NUL/C0) and stays valid', () => {
119
+ const xml = renderLaunchdPlist({ ...baseSpec, workingDirectory: '/a\x00b\x01c\x1ftab\tkeep' })
120
+ expect(xml).toContain('<string>/abctab\tkeep</string>') // controls gone, tab preserved
121
+ expect(xml).not.toContain('\x00')
122
+ const dir = mkTmp()
123
+ const f = join(dir, 'c.plist')
124
+ writeFileSync(f, xml)
125
+ expect(plutilLint(f).ok).toBe(true)
126
+ })
127
+ })
128
+
129
+ describe('installAlwaysOnPlist ↔ isLaunchdManaged round-trip', () => {
130
+ test('installs a valid notifier plist that isLaunchdManaged then detects', () => {
131
+ const root = mkTmp()
132
+ const laDir = join(root, 'LaunchAgents')
133
+ const cwd = join(root, 'peer-timer')
134
+ // IAPEER_ROOT keeps the now-GLOBAL infra log dir (~/.iapeer/logs/<p>) under the
135
+ // sandbox, not the real home (Фаза §8 moved infra logs out of the per-peer cwd).
136
+ const env = { IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
137
+
138
+ const path = installAlwaysOnPlist({
139
+ personality: 'timer',
140
+ runtime: 'notifier',
141
+ cwd,
142
+ entrypointArgv: ['/path/to/bun', '/pkg/src/launch/launchdRun.ts'],
143
+ path: '/usr/bin:/bin',
144
+ env,
145
+ })
146
+
147
+ expect(path).toBe(launchdPlistPath('timer', env))
148
+ expect(existsSync(path)).toBe(true)
149
+ // generator and H4 detector agree (shared launchdLabel/launchAgentsDir)
150
+ expect(isLaunchdManaged('timer', env)).toBe(true)
151
+ expect(isLaunchdManaged('nobody', env)).toBe(false)
152
+ // the installed file is a valid plist
153
+ expect(plutilLint(path).ok).toBe(true)
154
+ })
155
+
156
+ test('DEFAULT entrypoint (Ф-F) = the installed `iapeer run-infra <p> <r>` binary, not bun src', () => {
157
+ const root = mkTmp()
158
+ // HOME=/Users/x exercises the default binary path (~/.local/bin/iapeer); IAPEER_ROOT
159
+ // keeps the global infra log dir under the sandbox (not the fake /Users/x).
160
+ const env = {
161
+ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
162
+ HOME: '/Users/x',
163
+ IAPEER_ROOT: join(root, 'iapeer'),
164
+ } as NodeJS.ProcessEnv
165
+ // no entrypointArgv → the default must be the installed binary + run-infra verb
166
+ const path = installAlwaysOnPlist({ personality: 'timer', runtime: 'notifier', cwd: join(root, 'c'), env })
167
+ const xml = readFileSync(path, 'utf8')
168
+ expect(xml).toContain('<string>/Users/x/.local/bin/iapeer</string>')
169
+ expect(xml).toContain('<string>run-infra</string>')
170
+ expect(xml).toContain('<string>timer</string>')
171
+ expect(xml).toContain('<string>notifier</string>')
172
+ expect(xml).not.toContain('launchdRun.ts') // decoupled from the src tree
173
+ })
174
+
175
+ test('refuses a non-infra (warm-on-demand) runtime', () => {
176
+ const root = mkTmp()
177
+ expect(() =>
178
+ installAlwaysOnPlist({
179
+ personality: 'boris',
180
+ runtime: 'claude',
181
+ cwd: join(root, 'p'),
182
+ env: { IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents') } as NodeJS.ProcessEnv,
183
+ }),
184
+ ).toThrow(/not an always-on infra runtime/)
185
+ })
186
+ })
187
+
188
+ // The launchd Label com.iapeer.<personality> is keyed on PERSONALITY and SHARED
189
+ // with the live persistent-peer fleet (com.iapeer.arthur, com.iapeer.boris — real
190
+ // people/agents, launchd-held RIGHT NOW). A personality collision would point the
191
+ // installer at a PP-managed plist. H4 invariant: the foundation must NEVER clobber
192
+ // a plist it does not own. The guard tells "ours" from "theirs" by a sentinel the
193
+ // foundation renderer embeds — a PP/foreign plist lacks it → refuse, do not write.
194
+ describe('installAlwaysOnPlist collision guard (H4 — shared com.iapeer.* namespace)', () => {
195
+ // A persistent-peer start.sh plist for `boris` — same com.iapeer.boris Label, but
196
+ // NOT foundation-rendered (no sentinel). This stands in for a live PP-managed file.
197
+ const foreignPpPlist = `<?xml version="1.0" encoding="UTF-8"?>
198
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
199
+ <plist version="1.0">
200
+ <dict>
201
+ <key>Label</key>
202
+ <string>com.iapeer.boris</string>
203
+ <key>ProgramArguments</key>
204
+ <array>
205
+ <string>/Users/x/.iapeer/plugins/persistent-peer/start.sh</string>
206
+ <string>boris</string>
207
+ </array>
208
+ <key>RunAtLoad</key>
209
+ <true/>
210
+ <key>KeepAlive</key>
211
+ <true/>
212
+ </dict>
213
+ </plist>
214
+ `
215
+
216
+ test('REFUSES to overwrite a foreign (PP-managed) com.iapeer.* plist — no silent clobber', () => {
217
+ const root = mkTmp()
218
+ const laDir = join(root, 'LaunchAgents')
219
+ mkdirSync(laDir, { recursive: true })
220
+ const env = { IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
221
+ const path = launchdPlistPath('boris', env)
222
+ writeFileSync(path, foreignPpPlist) // a live PP peer's plist already sits here
223
+
224
+ expect(() =>
225
+ installAlwaysOnPlist({
226
+ personality: 'boris',
227
+ runtime: 'notifier',
228
+ cwd: join(root, 'peer-boris'),
229
+ entrypointArgv: ['/path/to/bun', '/pkg/src/launch/launchdRun.ts'],
230
+ path: '/usr/bin:/bin',
231
+ env,
232
+ }),
233
+ ).toThrow(/foundation-managed|refus/i)
234
+
235
+ // the foreign plist MUST be byte-for-byte untouched (the live PP peer survives)
236
+ expect(readFileSync(path, 'utf8')).toBe(foreignPpPlist)
237
+ })
238
+
239
+ test('isFoundationOwnedPlist: own (rendered) → true, foreign → false, absent → false', () => {
240
+ const dir = mkTmp()
241
+ const own = join(dir, 'own.plist')
242
+ writeFileSync(own, renderLaunchdPlist(baseSpec)) // foundation renderer → sentinel
243
+ expect(isFoundationOwnedPlist(own)).toBe(true)
244
+
245
+ const foreign = join(dir, 'foreign.plist')
246
+ writeFileSync(foreign, foreignPpPlist) // PP start.sh plist → no sentinel
247
+ expect(isFoundationOwnedPlist(foreign)).toBe(false)
248
+
249
+ expect(isFoundationOwnedPlist(join(dir, 'nope.plist'))).toBe(false) // absent
250
+ })
251
+
252
+ test('re-installing the foundation OWN plist is idempotent (sentinel present → allowed)', () => {
253
+ const root = mkTmp()
254
+ const laDir = join(root, 'LaunchAgents')
255
+ const env = { IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
256
+ const opts = {
257
+ personality: 'timer',
258
+ runtime: 'notifier' as const,
259
+ cwd: join(root, 'peer-timer'),
260
+ entrypointArgv: ['/path/to/bun', '/pkg/src/launch/launchdRun.ts'],
261
+ path: '/usr/bin:/bin',
262
+ env,
263
+ }
264
+ const path = installAlwaysOnPlist(opts) // first install — writes OUR plist
265
+ expect(isFoundationOwnedPlist(path)).toBe(true)
266
+ // second install over our own plist must NOT throw (idempotent re-provision)
267
+ expect(() => installAlwaysOnPlist(opts)).not.toThrow()
268
+ expect(plutilLint(path).ok).toBe(true)
269
+ })
270
+ })
271
+
272
+ // launchd gives a job a MINIMAL PATH (no ~/.local/bin, ~/.bun/bin), so an infra
273
+ // peer's always-on plist must PIN its launcher to an absolute path or the session
274
+ // crash-loops. installAlwaysOnPlist resolves the bin against the rich provisioning
275
+ // PATH and bakes NOTIFIER_RUNTIME_BIN / TELEGRAM_RUNTIME_BIN.
276
+ describe('resolveExecutable + runtime-bin pinning', () => {
277
+ function fakeExec(dir: string, name: string): string {
278
+ const p = join(dir, name)
279
+ writeFileSync(p, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
280
+ return p
281
+ }
282
+
283
+ test('resolveExecutable: finds on PATH, abs passthrough, undefined when absent', () => {
284
+ const dir = mkTmp()
285
+ const bin = fakeExec(dir, 'notifier-runtime')
286
+ expect(resolveExecutable('notifier-runtime', { PATH: dir } as NodeJS.ProcessEnv)).toBe(bin)
287
+ expect(resolveExecutable(bin, {} as NodeJS.ProcessEnv)).toBe(bin) // abs path, exists+exec
288
+ expect(resolveExecutable('no-such-bin', { PATH: dir } as NodeJS.ProcessEnv)).toBeUndefined()
289
+ expect(resolveExecutable('/nope/notifier-runtime', {} as NodeJS.ProcessEnv)).toBeUndefined()
290
+ })
291
+
292
+ test('explicit runtimeBin (abs) is baked into the plist env', () => {
293
+ const root = mkTmp()
294
+ const env = { IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'), IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
295
+ const path = installAlwaysOnPlist({
296
+ personality: 'timer', runtime: 'notifier', cwd: join(root, 'p'),
297
+ runtimeBin: '/opt/iapeer/notifier-runtime',
298
+ entrypointArgv: ['/bun', '/run.ts'], path: '/usr/bin:/bin', env,
299
+ })
300
+ const xml = readFileSync(path, 'utf8')
301
+ expect(xml).toContain('<key>NOTIFIER_RUNTIME_BIN</key>')
302
+ expect(xml).toContain('<string>/opt/iapeer/notifier-runtime</string>')
303
+ expect(plutilLint(path).ok).toBe(true)
304
+ })
305
+
306
+ test('runtimeBin omitted → default bin resolved from env.PATH and pinned', () => {
307
+ const root = mkTmp()
308
+ const bindir = mkTmp()
309
+ const bin = fakeExec(bindir, 'notifier-runtime')
310
+ const env = { IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'), PATH: bindir, IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
311
+ const path = installAlwaysOnPlist({
312
+ personality: 'timer', runtime: 'notifier', cwd: join(root, 'p'),
313
+ entrypointArgv: ['/bun', '/run.ts'], env,
314
+ })
315
+ expect(readFileSync(path, 'utf8')).toContain(`<string>${bin}</string>`)
316
+ })
317
+
318
+ test('unresolvable launcher → NOT baked (no crash; bare name + plist PATH remain)', () => {
319
+ const root = mkTmp()
320
+ const env = { IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'), PATH: join(root, 'empty'), IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
321
+ const path = installAlwaysOnPlist({
322
+ personality: 'timer', runtime: 'notifier', cwd: join(root, 'p'),
323
+ entrypointArgv: ['/bun', '/run.ts'], path: '/usr/bin:/bin', env,
324
+ })
325
+ expect(readFileSync(path, 'utf8')).not.toContain('NOTIFIER_RUNTIME_BIN')
326
+ })
327
+ })
328
+
329
+ describe('runAlwaysOn guard', () => {
330
+ test('a non-infra runtime is rejected with exit code 1 (no tmux touched)', async () => {
331
+ expect(await runAlwaysOn('boris', 'claude', '/tmp/whatever')).toBe(1)
332
+ })
333
+ })
334
+
335
+ // REGRESSION (Ф-A #3 adversarial-verify find): the always-on launch path MUST carry
336
+ // the peer's intelligence onto the spec, or the launch primitive's telegram nature
337
+ // gate (requires natural) fails `natural !== undefined` → exit 1 → launchd KeepAlive
338
+ // crash-loop on EVERY telegram bot. buildAlwaysOnSpec reads it from the local profile.
339
+ describe('buildAlwaysOnSpec carries intelligence (telegram crash-loop guard)', () => {
340
+ test('a provisioned telegram peer (intelligence=natural) → spec.intelligence=natural (clears the gate)', () => {
341
+ const cwd = mkdtempSync(join(tmpdir(), 'iapeer-alwayson-'))
342
+ mkdirSync(join(cwd, '.iapeer'), { recursive: true })
343
+ writeFileSync(
344
+ join(cwd, '.iapeer', 'peer-profile.json'),
345
+ JSON.stringify({ personality: 'mybot', runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'natural' }),
346
+ )
347
+ const spec = buildAlwaysOnSpec('mybot', 'telegram', cwd, '/tmp')
348
+ expect(spec.intelligence).toBe('natural')
349
+ // and the gate it feeds would pass: telegram + natural is not refused
350
+ rmSync(cwd, { recursive: true, force: true })
351
+ })
352
+
353
+ test('legacy human profile self-heals to natural on read → still clears the gate', () => {
354
+ const cwd = mkdtempSync(join(tmpdir(), 'iapeer-alwayson-'))
355
+ mkdirSync(join(cwd, '.iapeer'), { recursive: true })
356
+ writeFileSync(
357
+ join(cwd, '.iapeer', 'peer-profile.json'),
358
+ JSON.stringify({ personality: 'mybot', runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'human' }),
359
+ )
360
+ expect(buildAlwaysOnSpec('mybot', 'telegram', cwd, '/tmp').intelligence).toBe('natural')
361
+ rmSync(cwd, { recursive: true, force: true })
362
+ })
363
+ })