@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.
- package/bin/iapeer +25 -0
- package/package.json +37 -0
- package/src/cli/cli.test.ts +130 -0
- package/src/cli/index.ts +608 -0
- package/src/cli/listTui.test.ts +70 -0
- package/src/cli/listTui.ts +165 -0
- package/src/codec/codec.test.ts +271 -0
- package/src/codec/index.ts +217 -0
- package/src/core/constants.test.ts +21 -0
- package/src/core/constants.ts +180 -0
- package/src/core/errors.ts +20 -0
- package/src/core/index.ts +3 -0
- package/src/core/normalize.test.ts +98 -0
- package/src/core/normalize.ts +89 -0
- package/src/core/socket.ts +63 -0
- package/src/create/create.test.ts +143 -0
- package/src/create/index.ts +178 -0
- package/src/daemon/daemon-http.test.ts +114 -0
- package/src/daemon/daemon.test.ts +103 -0
- package/src/daemon/index.ts +439 -0
- package/src/daemon/main.test.ts +194 -0
- package/src/daemon/main.ts +230 -0
- package/src/enable/enable.test.ts +92 -0
- package/src/enable/index.ts +381 -0
- package/src/identity/identity.test.ts +262 -0
- package/src/identity/index.ts +603 -0
- package/src/index.ts +27 -0
- package/src/init/index.ts +408 -0
- package/src/init/init.test.ts +171 -0
- package/src/init/runtime-resolve.test.ts +49 -0
- package/src/install/index.ts +84 -0
- package/src/install/install.test.ts +31 -0
- package/src/launch/adapters/claude.ts +250 -0
- package/src/launch/adapters/codex.ts +329 -0
- package/src/launch/adapters/notifier.ts +90 -0
- package/src/launch/adapters/telegram.ts +130 -0
- package/src/launch/bootstrap.test.ts +56 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
- package/src/launch/composeSystemPrompt.test.ts +98 -0
- package/src/launch/composeSystemPrompt.ts +261 -0
- package/src/launch/index.ts +253 -0
- package/src/launch/launch.test.ts +233 -0
- package/src/launch/launchd.test.ts +363 -0
- package/src/launch/launchd.ts +375 -0
- package/src/launch/launchdRun.ts +168 -0
- package/src/launch/sockdir.test.ts +70 -0
- package/src/launch/types.ts +300 -0
- package/src/lifecycle/index.ts +840 -0
- package/src/lifecycle/lifecycle.test.ts +496 -0
- package/src/onboard/index.ts +135 -0
- package/src/onboard/onboard.test.ts +39 -0
- package/src/provision/index.ts +170 -0
- package/src/provision/provision.test.ts +104 -0
- package/src/registry/index.ts +453 -0
- package/src/registry/registry.test.ts +400 -0
- package/src/runtime/deploy.ts +230 -0
- package/src/runtime/index.ts +191 -0
- package/src/runtime/runtime.test.ts +226 -0
- package/src/storage/index.ts +331 -0
- package/src/storage/peers-home.test.ts +34 -0
- package/src/storage/storage.test.ts +65 -0
- package/src/transport/index.ts +522 -0
- 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 & b <weird></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
|
+
})
|