@agfpd/iapeer 0.2.77 → 0.2.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +2 -2
- package/package.json +21 -2
- package/src/cli/index.ts +47 -48
- package/src/connect/index.ts +4 -4
- package/src/core/constants.ts +3 -3
- package/src/core/normalize.ts +2 -2
- package/src/create/index.ts +1 -1
- package/src/daemon/index.ts +5 -5
- package/src/daemon/main.ts +9 -9
- package/src/enable/index.ts +19 -18
- package/src/enable/provisionCommand.ts +10 -10
- package/src/identity/index.ts +2 -2
- package/src/identity/profileStandard.ts +8 -8
- package/src/init/index.ts +10 -10
- package/src/install/index.ts +2 -2
- package/src/install/signing.ts +3 -4
- package/src/launch/adapters/claude.ts +7 -7
- package/src/launch/adapters/codex.ts +5 -6
- package/src/launch/canary.ts +8 -8
- package/src/launch/cmdlog.ts +3 -3
- package/src/launch/composeSystemPrompt.ts +2 -1
- package/src/launch/index.ts +5 -6
- package/src/launch/invocation.ts +1 -1
- package/src/launch/launchd.ts +14 -15
- package/src/launch/launchdRun.ts +6 -6
- package/src/launch/nativeMemory.ts +9 -9
- package/src/launch/ptyHost.ts +1 -1
- package/src/launch/readyGateModel.ts +3 -3
- package/src/launch/types.ts +7 -7
- package/src/lifecycle/eventlog.ts +2 -2
- package/src/lifecycle/index.ts +21 -21
- package/src/lifecycle/queue.ts +1 -1
- package/src/onboard/index.ts +19 -13
- package/src/onboard/memory.ts +5 -5
- package/src/onboard/steps.ts +8 -9
- package/src/provision/index.ts +2 -3
- package/src/runtime/deploy.ts +2 -2
- package/src/runtime/index.ts +4 -4
- package/src/shadow/gate.ts +6 -6
- package/src/shadow/index.ts +2 -2
- package/src/status/index.ts +11 -11
- package/src/storage/index.ts +1 -1
- package/src/supervisor/daemon.ts +14 -8
- package/src/transport/index.ts +11 -11
- package/src/update/index.ts +4 -4
- package/src/cli/cli.test.ts +0 -603
- package/src/cli/listTui.test.ts +0 -71
- package/src/codec/codec.test.ts +0 -271
- package/src/connect/connect.test.ts +0 -163
- package/src/core/constants.test.ts +0 -32
- package/src/core/normalize.test.ts +0 -98
- package/src/create/create.test.ts +0 -143
- package/src/daemon/daemon-http.test.ts +0 -114
- package/src/daemon/daemon.test.ts +0 -267
- package/src/daemon/main.test.ts +0 -413
- package/src/enable/enable.test.ts +0 -95
- package/src/enable/provisionCommand.test.ts +0 -107
- package/src/identity/identity.test.ts +0 -279
- package/src/identity/profileStandard.test.ts +0 -204
- package/src/init/init.test.ts +0 -215
- package/src/init/runtime-resolve.test.ts +0 -49
- package/src/install/install.test.ts +0 -66
- package/src/install/signing.test.ts +0 -84
- package/src/launch/bootdeliver.test.ts +0 -108
- package/src/launch/bootstrap.test.ts +0 -56
- package/src/launch/canary.test.ts +0 -346
- package/src/launch/cmdlog.test.ts +0 -94
- package/src/launch/codexHooksTrust.test.ts +0 -248
- package/src/launch/composeSystemPrompt.layers.test.ts +0 -445
- package/src/launch/composeSystemPrompt.test.ts +0 -145
- package/src/launch/hostComposerOccupied.test.ts +0 -63
- package/src/launch/invocation.test.ts +0 -174
- package/src/launch/launch.test.ts +0 -359
- package/src/launch/launchd.test.ts +0 -499
- package/src/launch/nativeMemory.test.ts +0 -193
- package/src/launch/ptyHost.test.ts +0 -38
- package/src/launch/readyGateModel.test.ts +0 -72
- package/src/launch/sockdir.test.ts +0 -74
- package/src/lifecycle/eventlog.test.ts +0 -114
- package/src/lifecycle/lifecycle.test.ts +0 -1056
- package/src/lifecycle/queue.test.ts +0 -185
- package/src/onboard/memory.test.ts +0 -157
- package/src/onboard/onboard.test.ts +0 -39
- package/src/onboard/steps.test.ts +0 -201
- package/src/provision/provision.test.ts +0 -288
- package/src/registry/registry.test.ts +0 -432
- package/src/runtime/runtime.test.ts +0 -226
- package/src/runtime/update.test.ts +0 -192
- package/src/shadow/enumerate-skip.test.ts +0 -67
- package/src/shadow/gate.test.ts +0 -286
- package/src/shadow/install.test.ts +0 -153
- package/src/shadow/shadow.test.ts +0 -42
- package/src/status/status.test.ts +0 -208
- package/src/storage/peers-home.test.ts +0 -34
- package/src/storage/rotatelog.test.ts +0 -44
- package/src/storage/storage.test.ts +0 -65
- package/src/supervisor/attached-marker.test.ts +0 -84
- package/src/supervisor/boot.test.ts +0 -66
- package/src/supervisor/client.attach.test.ts +0 -107
- package/src/supervisor/daemon.test.ts +0 -213
- package/src/supervisor/deliver.test.ts +0 -58
- package/src/supervisor/panelog.test.ts +0 -95
- package/src/supervisor/protocol.test.ts +0 -222
- package/src/supervisor/selfargv.test.ts +0 -22
- package/src/supervisor/spinner-strip.test.ts +0 -52
- package/src/transport/compactDone.test.ts +0 -144
- package/src/transport/composerQueue.test.ts +0 -204
- package/src/transport/hostControl.test.ts +0 -87
- package/src/transport/hostDeliver.test.ts +0 -137
- package/src/transport/hostLive.test.ts +0 -50
- package/src/transport/routesend-wake.test.ts +0 -357
- package/src/update/update.test.ts +0 -208
|
@@ -1,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 & b <weird></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
|
-
})
|