@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,375 @@
1
+ // launchd — always-on plist generation for INFRA runtimes (notifier, telegram).
2
+ // The foundation DEPLOYS the launchd LaunchAgent that holds an infra peer live
3
+ // (KeepAlive) AND reads it back for the H4 sweep-guard (lifecycle.isLaunchdManaged).
4
+ // Both sides MUST agree on the label + dir scheme, so those are the SINGLE shared
5
+ // helpers here (lifecycle imports them) — there is no second place that spells
6
+ // `com.iapeer.<personality>.plist`, so the generator and the detector cannot drift.
7
+ //
8
+ // The plist runs the always-on entrypoint (launchdRun.ts): it brings the peer up
9
+ // in a tmux session (launch alwaysOn → a live pane/socket for the daemon's
10
+ // deliverViaTmux to paste send_to_peer envelopes into) and blocks until the session
11
+ // dies → KeepAlive respawns. ThrottleInterval PINS launchd's respawn floor EXPLICITLY
12
+ // (launchd's own default is also 10s, so this restates rather than widens it — set
13
+ // here so the crashloop bound is visible and tunable, not an implicit default; raise
14
+ // throttleIntervalSecs for a wider window). RunAtLoad+KeepAlive = always-on.
15
+
16
+ import { accessSync, constants as FS, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
17
+ import { homedir } from 'os'
18
+ import { join } from 'path'
19
+ import { spawnSync } from 'child_process'
20
+ import { iapeerBinPath } from '../install/index.ts'
21
+ import {
22
+ IAPEER_DIR,
23
+ INFRA_RUNTIME_BIN_ENV,
24
+ INFRA_RUNTIME_DEFAULT_BIN,
25
+ LAUNCHD_LABEL_PREFIX,
26
+ isInfraRuntime,
27
+ type Runtime,
28
+ } from '../core/constants.ts'
29
+ import { buildProcessAddress } from '../core/socket.ts'
30
+ import { peerLogsDir } from '../storage/index.ts'
31
+ import { IapError } from '../core/errors.ts'
32
+
33
+ const DEFAULT_THROTTLE_SECS = 10
34
+
35
+ /**
36
+ * OWNERSHIP SENTINEL — an inert top-level plist key the foundation renderer ALWAYS
37
+ * embeds, marking a plist as foundation-managed. The launchd Label is keyed on
38
+ * personality (`com.iapeer.<p>`) and SHARED with the live persistent-peer fleet, so
39
+ * the label alone CANNOT tell a foundation plist from a PP-managed one. This key
40
+ * can: the proof of ownership travels WITH the artifact (no side ownership registry
41
+ * to drift), so the install guard refuses to clobber any com.iapeer.* plist that
42
+ * lacks it. launchd ignores keys it does not recognize, so the marker is inert at
43
+ * load time (and never reaches the runtime process, unlike an env var would).
44
+ * NOT a Label: a Label value renders inside `<string>`, this only ever appears as
45
+ * `<key>…</key>`, so the detection substring can never collide with a peer named
46
+ * "managed". Bumping this string is a breaking change (older plists read as foreign).
47
+ */
48
+ export const IAPEER_PLIST_OWNER_KEY = 'com.iapeer.managed'
49
+
50
+ /**
51
+ * True iff the plist file at `path` was rendered by the foundation (carries the
52
+ * ownership sentinel). A foreign / persistent-peer plist at the same com.iapeer.*
53
+ * label lacks it → false. An absent or unreadable file → false (not provably ours,
54
+ * so the guard treats it as foreign and refuses). Substring match is reliable
55
+ * because renderLaunchdPlist emits the sentinel verbatim as a `<key>` node.
56
+ */
57
+ export function isFoundationOwnedPlist(path: string): boolean {
58
+ try {
59
+ return readFileSync(path, 'utf8').includes(`<key>${IAPEER_PLIST_OWNER_KEY}</key>`)
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Resolve an executable to an ABSOLUTE path against `env.PATH` (the RICH
67
+ * provisioning PATH), so the result can be baked into a launchd plist whose own
68
+ * PATH is minimal. A name containing '/' is treated as a path and returned iff it
69
+ * is an executable file. Returns undefined when nothing executable is found.
70
+ * Pure PATH scan (no `which` dependency) — deterministic and testable.
71
+ */
72
+ export function resolveExecutable(bin: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
73
+ const isExec = (p: string): boolean => {
74
+ try {
75
+ accessSync(p, FS.X_OK)
76
+ return true
77
+ } catch {
78
+ return false
79
+ }
80
+ }
81
+ if (bin.includes('/')) return isExec(bin) ? bin : undefined
82
+ for (const dir of (env.PATH ?? '').split(':')) {
83
+ if (!dir) continue
84
+ const p = join(dir, bin)
85
+ if (isExec(p)) return p
86
+ }
87
+ return undefined
88
+ }
89
+
90
+ /** `com.iapeer.<personality>` — the launchd Label AND the plist basename stem.
91
+ * The single source for the scheme; isLaunchdManaged reads the same. */
92
+ export function launchdLabel(personality: string): string {
93
+ return `${LAUNCHD_LABEL_PREFIX}${personality}`
94
+ }
95
+
96
+ /** The LaunchAgents dir: IAPEER_LAUNCHAGENTS_DIR override (tests/sandbox) else
97
+ * ~/Library/LaunchAgents. Shared with isLaunchdManaged. */
98
+ export function launchAgentsDir(env: NodeJS.ProcessEnv = process.env): string {
99
+ return env.IAPEER_LAUNCHAGENTS_DIR?.trim() || join(env.HOME?.trim() || homedir(), 'Library', 'LaunchAgents')
100
+ }
101
+
102
+ export function launchdPlistPath(personality: string, env: NodeJS.ProcessEnv = process.env): string {
103
+ return join(launchAgentsDir(env), `${launchdLabel(personality)}.plist`)
104
+ }
105
+
106
+ // XML-escape a plist <string> text node. cwd / personality / PATH can carry '&',
107
+ // '<', '>' (or unicode) — a literal '&' or '<' would corrupt the XML, so escape the
108
+ // three significant characters. Also DROP XML-1.0-illegal control characters (NUL +
109
+ // the C0 set except tab/LF/CR): they are not representable in XML 1.0 text and `plutil`
110
+ // only leniently tolerates them. Inputs here are NAME_RE-clean personalities and real
111
+ // filesystem paths, so this is belt-and-suspenders, never lossy in practice.
112
+ function xmlEscape(value: string): string {
113
+ return value
114
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
115
+ .replace(/&/g, '&amp;')
116
+ .replace(/</g, '&lt;')
117
+ .replace(/>/g, '&gt;')
118
+ }
119
+
120
+ export interface LaunchdPlistSpec {
121
+ label: string
122
+ programArguments: string[]
123
+ workingDirectory: string
124
+ environment: Record<string, string>
125
+ stdoutPath: string
126
+ stderrPath: string
127
+ /** Seconds launchd waits before respawning a fast-exiting job (crashloop
128
+ * circuit). Default 10 — explicit, so a broken infra peer cannot respawn-storm. */
129
+ throttleIntervalSecs?: number
130
+ }
131
+
132
+ /** Render a launchd LaunchAgent plist (RunAtLoad + KeepAlive = always-on). PURE
133
+ * and deterministic — golden/lint-testable. */
134
+ export function renderLaunchdPlist(spec: LaunchdPlistSpec): string {
135
+ const throttle = spec.throttleIntervalSecs ?? DEFAULT_THROTTLE_SECS
136
+ const args = spec.programArguments.map(a => ` <string>${xmlEscape(a)}</string>`).join('\n')
137
+ const envEntries = Object.entries(spec.environment)
138
+ .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
139
+ .join('\n')
140
+ return `<?xml version="1.0" encoding="UTF-8"?>
141
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
142
+ <plist version="1.0">
143
+ <dict>
144
+ <key>Label</key>
145
+ <string>${xmlEscape(spec.label)}</string>
146
+ <key>${IAPEER_PLIST_OWNER_KEY}</key>
147
+ <true/>
148
+ <key>ProgramArguments</key>
149
+ <array>
150
+ ${args}
151
+ </array>
152
+ <key>WorkingDirectory</key>
153
+ <string>${xmlEscape(spec.workingDirectory)}</string>
154
+ <key>EnvironmentVariables</key>
155
+ <dict>
156
+ ${envEntries}
157
+ </dict>
158
+ <key>RunAtLoad</key>
159
+ <true/>
160
+ <key>KeepAlive</key>
161
+ <true/>
162
+ <key>ThrottleInterval</key>
163
+ <integer>${throttle}</integer>
164
+ <key>StandardOutPath</key>
165
+ <string>${xmlEscape(spec.stdoutPath)}</string>
166
+ <key>StandardErrorPath</key>
167
+ <string>${xmlEscape(spec.stderrPath)}</string>
168
+ </dict>
169
+ </plist>
170
+ `
171
+ }
172
+
173
+ /** Default ProgramArguments prefix (Ф-F): the INSTALLED `iapeer` binary running the
174
+ * always-on infra entrypoint (`iapeer run-infra`), NOT `bun launchdRun.ts` — prod is
175
+ * decoupled from the src tree. The personality + runtime positionals are appended by
176
+ * install. (The pre-Ф-F `[bun, launchdRun.ts]` is overridable via entrypointArgv for
177
+ * tests / a tree-run dev layout.) */
178
+ function defaultEntrypointArgv(env: NodeJS.ProcessEnv = process.env): string[] {
179
+ return [iapeerBinPath(env), 'run-infra']
180
+ }
181
+
182
+ // ─────────────────────────────────────────────────────────────────────────────
183
+ // launchctl bootstrap — AUTO-load a freshly-provisioned foundation plist
184
+ // ─────────────────────────────────────────────────────────────────────────────
185
+
186
+ /** Resolve the current gui-domain uid for `launchctl bootstrap gui/<uid>`. NEVER
187
+ * falls back to 0 (that would aim the ROOT gui domain — audit #29); a non-numeric
188
+ * `id -u` result throws. */
189
+ function currentUid(): string {
190
+ const r = spawnSync('id', ['-u'], { encoding: 'utf8' })
191
+ const u = (r.stdout ?? '').trim()
192
+ if (!/^\d+$/.test(u)) {
193
+ throw new IapError('cannot resolve the current uid (id -u failed) — refusing to target launchctl at an unknown domain')
194
+ }
195
+ return u
196
+ }
197
+
198
+ export type BootstrapState =
199
+ | 'loaded' // bootstrapped now (was not loaded)
200
+ | 'already-loaded' // service already in the gui domain → no-op (idempotent)
201
+ | 'skipped-sandbox' // IAPEER_TEST_SANDBOX=1 → never touch the real launchd
202
+ | 'refused-foreign' // the plist is not foundation-owned → never load someone else's
203
+ | 'failed' // launchctl bootstrap exited non-zero
204
+
205
+ export interface BootstrapResult {
206
+ state: BootstrapState
207
+ label: string
208
+ detail?: string
209
+ }
210
+
211
+ /** Is `com.iapeer.<personality>` already loaded in the gui domain? (`launchctl print`
212
+ * exits 0 when the service exists.) Used to make bootstrap idempotent. */
213
+ function isLaunchdLoaded(label: string, uid: string): boolean {
214
+ return spawnSync('launchctl', ['print', `gui/${uid}/${label}`], { stdio: 'ignore' }).status === 0
215
+ }
216
+
217
+ /**
218
+ * AUTO-bootstrap a freshly-provisioned foundation plist into the gui domain
219
+ * (`launchctl bootstrap gui/<uid> <plist>`) — the "load it now, don't write-and-wait
220
+ * for the operator" step (contract Установка / Фаза §5). Designed to be SAFE on a
221
+ * live host:
222
+ * - FLEET GUARD: refuses any plist that is not foundation-owned (lacks the
223
+ * ownership sentinel) — a foreign / persistent-peer plist at the shared
224
+ * com.iapeer.* label is never loaded by us (`refused-foreign`).
225
+ * - IDEMPOTENT: a service already in the gui domain is a no-op (`already-loaded`),
226
+ * so a repeat provision/create never errors on a double bootstrap.
227
+ * - SANDBOX FAIL-SAFE: under IAPEER_TEST_SANDBOX=1 it NEVER calls launchctl
228
+ * (`bootstrap gui/<uid>` is host-global regardless of where the plist file lives,
229
+ * so a test must not load a real launchd job). Returns `skipped-sandbox`.
230
+ * A live e2e proof runs WITHOUT IAPEER_TEST_SANDBOX (isolated IAPEER_ROOT + a
231
+ * non-fleet personality) so this actually loads — additive and reversible (bootout).
232
+ */
233
+ export function launchctlBootstrap(
234
+ personality: string,
235
+ plistPath: string,
236
+ env: NodeJS.ProcessEnv = process.env,
237
+ ): BootstrapResult {
238
+ const label = launchdLabel(personality)
239
+ if (!isFoundationOwnedPlist(plistPath)) {
240
+ return {
241
+ state: 'refused-foreign',
242
+ label,
243
+ detail: `${plistPath} is not foundation-owned (no ${IAPEER_PLIST_OWNER_KEY} sentinel) — refusing to launchctl bootstrap a foreign plist`,
244
+ }
245
+ }
246
+ // SANDBOX FAIL-CLOSED: `launchctl bootstrap gui/<uid>` is HOST-GLOBAL — it loads a
247
+ // real launchd job regardless of where the plist file lives or what IAPEER_ROOT is.
248
+ // So the skip MUST consult BOTH the passed env AND the PROCESS env: a test harness
249
+ // that passes an explicit env (isolated IAPEER_ROOT) but omits the flag would
250
+ // otherwise bypass the guard and load a real job (this exact hole bit B1 once). The
251
+ // process-level flag (set by `bun test`) forces the skip even then — mirror of the
252
+ // registry's fail-closed sandbox lesson. A live e2e proof runs with NEITHER flag set.
253
+ if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') {
254
+ return { state: 'skipped-sandbox', label, detail: 'IAPEER_TEST_SANDBOX=1 — not loading a real launchd job' }
255
+ }
256
+ const uid = currentUid()
257
+ if (isLaunchdLoaded(label, uid)) return { state: 'already-loaded', label }
258
+ const r = spawnSync('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { encoding: 'utf8' })
259
+ if (r.status === 0) return { state: 'loaded', label }
260
+ // A race could have loaded it between the check and the bootstrap; treat a
261
+ // now-loaded service as success (still idempotent).
262
+ if (isLaunchdLoaded(label, uid)) return { state: 'already-loaded', label }
263
+ return { state: 'failed', label, detail: (r.stderr ?? '').trim() || `launchctl bootstrap exited ${r.status}` }
264
+ }
265
+
266
+ export interface InstallAlwaysOnPlistOptions {
267
+ personality: string
268
+ runtime: Runtime
269
+ cwd: string
270
+ /** How to invoke the always-on entrypoint, WITHOUT the trailing personality/
271
+ * runtime (those are appended). Defaults to [bun, launchdRun.ts]. */
272
+ entrypointArgv?: string[]
273
+ /** PATH for the launchd minimal env (default: bun/local/homebrew/usr/bin). */
274
+ path?: string
275
+ /** Absolute path to the infra runtime's launcher binary, baked into the plist
276
+ * env (NOTIFIER_RUNTIME_BIN / TELEGRAM_RUNTIME_BIN) so the launchd-minimal PATH
277
+ * can resolve it. When omitted, the default bin is resolved against env.PATH
278
+ * (best-effort); unresolved → not baked (the bare name + plist PATH remain). */
279
+ runtimeBin?: string
280
+ env?: NodeJS.ProcessEnv
281
+ throttleIntervalSecs?: number
282
+ }
283
+
284
+ /**
285
+ * Generate and install the always-on launchd plist for an INFRA peer, returning
286
+ * the written path. Gated on isInfraRuntime (a warm-on-demand claude/codex peer is
287
+ * daemon-managed, never launchd-held — installing a plist would flip it to H4
288
+ * read-only and break wake). The plist's ProgramArguments run the always-on
289
+ * entrypoint with PEER_* env + WorkingDirectory=cwd; logs land under
290
+ * <cwd>/.iapeer/logs/<runtime>/.
291
+ *
292
+ * COLLISION GUARD (H4 — shared label namespace): the launchd Label is
293
+ * com.iapeer.<personality> — keyed on PERSONALITY, not identity, and SHARED with the
294
+ * already-deployed persistent-peer fleet (~/Library/LaunchAgents/
295
+ * com.iapeer.<persistent-peer>.plist run by start.sh). A personality collision (a
296
+ * notifier peer named like a live PP peer) must NOT silently overwrite that foreign
297
+ * plist — doing so would tear a live PP peer off launchd. So before writing we
298
+ * REFUSE when a plist already sits at the target and is not foundation-owned
299
+ * (isFoundationOwnedPlist: it lacks the sentinel renderLaunchdPlist embeds). The
300
+ * label prefix alone cannot tell ours from theirs (PP is com.iapeer.* too); the
301
+ * sentinel can. Re-installing our OWN plist (sentinel present) is allowed
302
+ * (idempotent re-provision). The guard is checked FIRST, before any mkdir/write, so
303
+ * a refusal leaves the filesystem untouched.
304
+ */
305
+ export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string {
306
+ if (!isInfraRuntime(opts.runtime)) {
307
+ throw new IapError(
308
+ `runtime "${opts.runtime}" is not an always-on infra runtime; no launchd plist generated`,
309
+ )
310
+ }
311
+ const env = opts.env ?? process.env
312
+ // Collision guard FIRST — never clobber a plist the foundation does not own.
313
+ const path = launchdPlistPath(opts.personality, env)
314
+ if (existsSync(path) && !isFoundationOwnedPlist(path)) {
315
+ throw new IapError(
316
+ `refusing to overwrite launchd plist ${path}: label ${launchdLabel(opts.personality)} ` +
317
+ `is not foundation-managed (no ${IAPEER_PLIST_OWNER_KEY} sentinel) — a persistent-peer ` +
318
+ `or other manager owns it; rename the peer to avoid the com.iapeer.<personality> collision`,
319
+ )
320
+ }
321
+ // GLOBAL infra logs (Фаза §8): ~/.iapeer/logs/<personality>/, NOT per-peer
322
+ // <cwd>/.iapeer/logs/ — host-service logs live in the global log area.
323
+ const logDir = peerLogsDir(opts.personality, { env })
324
+ // Resolve home the SAME way launchAgentsDir does (env.HOME first) so a test/
325
+ // sandbox overriding HOME keeps the PATH fallback and the plist dir in step.
326
+ const home = env.HOME?.trim() || homedir()
327
+ const defaultPath = `${home}/.bun/bin:${home}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`
328
+ const environment: Record<string, string> = {
329
+ PEER_PERSONALITY: opts.personality,
330
+ PEER_RUNTIME: opts.runtime,
331
+ PEER_IDENTITY: buildProcessAddress(opts.runtime, opts.personality),
332
+ PATH: opts.path ?? env.PATH ?? defaultPath,
333
+ }
334
+ // Propagate the non-default path overrides into the plist env (mirror of the daemon
335
+ // plist, audit #26): in PRODUCTION these are unset → nothing is baked, the always-on
336
+ // session uses the real ~/.iapeer + /tmp sockets (correct). In a SANDBOX they ARE set
337
+ // → baked, so a sandboxed infra peer's run-infra resolves the SAME isolated root +
338
+ // socket dir the provision used (its tmux endpoint lands in the sandbox, not /tmp).
339
+ // This is what lets a live e2e proof be fully isolated AND leaves prod unchanged.
340
+ for (const key of ['IAPEER_ROOT', 'IAPEER_SOCK_DIR', 'IAPEER_LAUNCHAGENTS_DIR'] as const) {
341
+ if (env[key]?.trim()) environment[key] = env[key]!.trim()
342
+ }
343
+ // Pin the infra runtime's launcher to an ABSOLUTE path so launchd's minimal PATH
344
+ // can find it (a bare name would crash-loop the always-on session). opts.runtimeBin
345
+ // wins; else resolve the runtime's default bin against the rich provisioning
346
+ // env.PATH. Unresolved → leave it out (the bare name + plist PATH still apply).
347
+ const binEnvVar = INFRA_RUNTIME_BIN_ENV[opts.runtime]
348
+ if (binEnvVar) {
349
+ const resolved = opts.runtimeBin ?? resolveExecutable(INFRA_RUNTIME_DEFAULT_BIN[opts.runtime] ?? opts.runtime, env)
350
+ if (resolved) environment[binEnvVar] = resolved
351
+ }
352
+ const spec: LaunchdPlistSpec = {
353
+ label: launchdLabel(opts.personality),
354
+ programArguments: [...(opts.entrypointArgv ?? defaultEntrypointArgv(env)), opts.personality, opts.runtime],
355
+ workingDirectory: opts.cwd,
356
+ environment,
357
+ stdoutPath: join(logDir, 'launchd-stdout.log'),
358
+ stderrPath: join(logDir, 'launchd-stderr.log'),
359
+ throttleIntervalSecs: opts.throttleIntervalSecs,
360
+ }
361
+ mkdirSync(launchAgentsDir(env), { recursive: true })
362
+ // The global infra log dir now resolves under ~/.iapeer (Фаза §8). Under a sandbox
363
+ // (test) run, NEVER mkdir under the REAL ~/.iapeer — a test that forgot IAPEER_ROOT
364
+ // would otherwise create real ~/.iapeer/logs/<p>. Skip the mkdir then (the plist
365
+ // still carries the path; a real run — no sandbox flag — always makes it). Consult
366
+ // process.env too, since a test passes an explicit env without the flag. Mirror of
367
+ // the registry/install fail-closed sandbox guards.
368
+ const sandbox = env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1'
369
+ const realRoot = join(homedir(), IAPEER_DIR)
370
+ if (!(sandbox && logDir.startsWith(`${realRoot}/`))) {
371
+ mkdirSync(logDir, { recursive: true, mode: 0o700 })
372
+ }
373
+ writeFileSync(path, renderLaunchdPlist(spec), { mode: 0o644 })
374
+ return path
375
+ }
@@ -0,0 +1,168 @@
1
+ // launchd always-on entrypoint — the blocking process launchd KeepAlive holds for
2
+ // an INFRA (always-on) peer. It brings the peer's session up via the launch
3
+ // primitive in alwaysOn mode (tmux endpoint for the daemon's deliverViaTmux; NO
4
+ // self-TTL) and then BLOCKS until that session dies, at which point it exits so
5
+ // launchd respawns it (the plist's ThrottleInterval bounds a crashloop).
6
+ //
7
+ // IDEMPOTENT: if the session is already live (a prior instance, or a manual
8
+ // bring-up), it skips the launch and only block-watches — never a second spawn.
9
+ // The bring-up is a check-then-launch with NO advisory lock (unlike wakeOrSpawn's
10
+ // withWakeLock): serialization is delegated to launchd, which runs at most one
11
+ // instance per Label. Do NOT invoke this manually alongside the launchd-managed job
12
+ // for the same identity — two concurrent racers could both pass the liveness check.
13
+ //
14
+ // Invoked by the generated plist (launchd.ts installAlwaysOnPlist):
15
+ // bun <this> <personality> <runtime>
16
+ // with WorkingDirectory=<peer cwd> and EnvironmentVariables PEER_* set by launchd,
17
+ // so process.cwd() IS the peer cwd.
18
+
19
+ import { spawnSync } from 'child_process'
20
+ import { join } from 'path'
21
+ import { INFRA_RUNTIME_BIN_ENV, isInfraRuntime, resolveSockDir } from '../core/constants.ts'
22
+ import { buildProcessAddress, buildSocketPath } from '../core/socket.ts'
23
+ import { peerLogsDir } from '../storage/index.ts'
24
+ import { readPeerProfile } from '../identity/index.ts'
25
+ import { getAdapter, launch } from './index.ts'
26
+ import type { LaunchConfig, LaunchSpec } from './types.ts'
27
+
28
+ /** Block-watch poll cadence — seconds, deliberately NOT a tight loop (the session
29
+ * rarely dies; this only needs to notice a crash within a few seconds). The sleep
30
+ * is cancelable, so a shutdown signal does not wait out a full interval. */
31
+ const WATCH_INTERVAL_MS = 5000
32
+
33
+ /** After a router launch returns READY (= tmux new-session succeeded), wait this
34
+ * long and recheck: a missing/broken runtime bin lets the pane die instantly, and
35
+ * this turns that into a NON-zero diagnostic exit instead of a clean-looking run. */
36
+ const BOOT_RECHECK_MS = 2000
37
+
38
+ function sessionAlive(sock: string, identity: string): boolean {
39
+ return spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity], { stdio: 'ignore' }).status === 0
40
+ }
41
+
42
+ /**
43
+ * Build the always-on LaunchSpec for an infra peer, reading intelligence from the
44
+ * local peer-profile.json. launchd sets WorkingDirectory = peer cwd, so that file
45
+ * is the authoritative per-peer source (it self-heals legacy human→natural on read).
46
+ *
47
+ * The intelligence field is LOAD-BEARING for the launch primitive's nature gate
48
+ * (telegram requires natural). Omitting it (the original bug) made every always-on
49
+ * telegram launch fail the gate (`natural !== undefined`) → exit 1 → launchd
50
+ * KeepAlive crash-loop. A correctly-provisioned telegram peer (intelligence=natural)
51
+ * now clears the gate; a mis-provisioned one is refused LOUDLY, not crash-looped.
52
+ * Exported so this invariant is unit-testable WITHOUT touching tmux.
53
+ */
54
+ export function buildAlwaysOnSpec(
55
+ personality: string,
56
+ runtime: string,
57
+ cwd: string,
58
+ sockDir: string,
59
+ ): LaunchSpec {
60
+ const profile = readPeerProfile(cwd)
61
+ return {
62
+ personality,
63
+ runtime,
64
+ cwd,
65
+ identity: buildProcessAddress(runtime, personality),
66
+ socketPath: buildSocketPath(runtime, personality, sockDir),
67
+ intelligence: profile?.intelligence,
68
+ }
69
+ }
70
+
71
+ function sleep(ms: number): Promise<void> {
72
+ return new Promise(resolve => setTimeout(resolve, ms))
73
+ }
74
+
75
+ /**
76
+ * Bring up (if needed) and block-watch one always-on infra session. Returns the
77
+ * process exit code: 0 when the watched session died (→ KeepAlive respawns),
78
+ * 1 when bring-up failed or the runtime is not infra.
79
+ */
80
+ export async function runAlwaysOn(personality: string, runtime: string, cwd: string): Promise<number> {
81
+ if (!isInfraRuntime(runtime)) {
82
+ process.stderr.write(`launchdRun: "${runtime}" is not an always-on infra runtime\n`)
83
+ return 1
84
+ }
85
+ const env = process.env
86
+ const sockDir = resolveSockDir(env)
87
+ const identity = buildProcessAddress(runtime, personality)
88
+ const sock = buildSocketPath(runtime, personality, sockDir)
89
+ const adapter = getAdapter(runtime)
90
+
91
+ const cfg: LaunchConfig = {
92
+ claudeBin: env.CLAUDE_BIN ?? 'claude',
93
+ codexBin: env.CODEX_BIN ?? 'codex',
94
+ // Read the abs runtime-bin the plist baked, via the SAME var-name map the baker
95
+ // (installAlwaysOnPlist) uses — so the pin and the read can never drift.
96
+ telegramBin: env[INFRA_RUNTIME_BIN_ENV.telegram],
97
+ notifierBin: env[INFRA_RUNTIME_BIN_ENV.notifier],
98
+ sockDir,
99
+ bootDeadlineSecs: 30,
100
+ readyGateSecs: 30,
101
+ maxAgeSecs: 0, // unused — alwaysOn skips the self-TTL
102
+ // GLOBAL infra logs (Фаза §8): ~/.iapeer/logs/<personality>/ — match the plist's
103
+ // stdout/stderr dir (installAlwaysOnPlist), not per-peer <cwd>/.iapeer/logs/.
104
+ logDir: peerLogsDir(personality, { env }),
105
+ env,
106
+ alwaysOn: true,
107
+ }
108
+ // Intelligence MUST be on the spec so the launch primitive's nature gate
109
+ // (telegram requires natural) passes for a correctly-provisioned infra peer
110
+ // (see buildAlwaysOnSpec — omitting it crash-looped every telegram launch).
111
+ const spec = buildAlwaysOnSpec(personality, runtime, cwd, sockDir)
112
+
113
+ // Idempotent: bring up only when not already live.
114
+ if (!sessionAlive(sock, identity)) {
115
+ const result = await launch(spec, adapter, '', cfg)
116
+ if (result.status !== 'READY') {
117
+ process.stderr.write(`launchdRun: launch FAILED for ${identity}: ${result.reason ?? 'unknown'}\n`)
118
+ return 1 // exit → launchd respawns after ThrottleInterval
119
+ }
120
+ // A router returns READY the moment `tmux new-session` succeeds — it does NOT
121
+ // verify the pane command STAYED up. If the runtime bin is missing/broken the
122
+ // session dies at once; recheck after a beat so a crash-on-boot exits NON-zero
123
+ // (a diagnostic, throttled by the plist) instead of a clean exit that reads as a
124
+ // healthy run in the launchd logs.
125
+ await sleep(BOOT_RECHECK_MS)
126
+ if (!sessionAlive(sock, identity)) {
127
+ process.stderr.write(
128
+ `launchdRun: ${identity} session died immediately after launch — check ${cfg.notifierBin ?? `${runtime}-runtime`}\n`,
129
+ )
130
+ return 1
131
+ }
132
+ }
133
+
134
+ // Block-watch until the session dies, then exit 0 so KeepAlive respawns a fresh
135
+ // bring-up. The per-iteration sleep is CANCELABLE: SIGTERM/SIGINT (launchctl
136
+ // bootout / clean shutdown) clears the pending timer and breaks the loop at once,
137
+ // so shutdown does not wait out a full poll interval.
138
+ let stop = false
139
+ let interrupt: (() => void) | null = null
140
+ const onSignal = () => {
141
+ stop = true
142
+ interrupt?.()
143
+ }
144
+ process.on('SIGTERM', onSignal)
145
+ process.on('SIGINT', onSignal)
146
+ while (!stop && sessionAlive(sock, identity)) {
147
+ await new Promise<void>(resolve => {
148
+ const timer = setTimeout(resolve, WATCH_INTERVAL_MS)
149
+ interrupt = () => {
150
+ clearTimeout(timer)
151
+ resolve()
152
+ }
153
+ })
154
+ interrupt = null
155
+ }
156
+ return 0
157
+ }
158
+
159
+ // CLI entry: bun launchdRun.ts <personality> <runtime>. cwd = launchd WorkingDirectory.
160
+ if (import.meta.main) {
161
+ const personality = process.argv[2] ?? process.env.PEER_PERSONALITY ?? ''
162
+ const runtime = process.argv[3] ?? process.env.PEER_RUNTIME ?? ''
163
+ if (!personality || !runtime) {
164
+ process.stderr.write('usage: launchdRun <personality> <runtime>\n')
165
+ process.exit(2)
166
+ }
167
+ runAlwaysOn(personality, runtime, process.cwd()).then(code => process.exit(code))
168
+ }
@@ -0,0 +1,70 @@
1
+ // Regression: launch() must `mkdir -p` the socket's parent dir before
2
+ // `tmux new-session -S <sock>` — tmux does NOT create it and fails silently
3
+ // ("session died immediately") when the IAPEER_SOCK_DIR override points at a
4
+ // not-yet-created dir (prod sock=/tmp always exists; this bit a sandbox pilot).
5
+
6
+ import { afterEach, describe, expect, test } from 'bun:test'
7
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
8
+ import { tmpdir } from 'os'
9
+ import { dirname, join } from 'path'
10
+ import { spawnSync } from 'child_process'
11
+ import { launch } from './index.ts'
12
+ import { notifierAdapter } from './adapters/notifier.ts'
13
+ import type { LaunchConfig, LaunchSpec } from './types.ts'
14
+
15
+ const dirs: string[] = []
16
+ function mkTmp(): string {
17
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-sockdir-'))
18
+ dirs.push(d)
19
+ return d
20
+ }
21
+ afterEach(() => {
22
+ while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
23
+ })
24
+
25
+ const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
26
+
27
+ describe('launch creates a missing socket dir', () => {
28
+ test.if(tmuxAvailable)('router launch into a NON-EXISTENT IAPEER_SOCK_DIR comes up (dir auto-created)', async () => {
29
+ const root = mkTmp()
30
+ const sockDir = join(root, 'does', 'not', 'exist', 'yet') // deep, absent
31
+ const sock = join(sockDir, 'tmux-iap-notifier-sockt.sock')
32
+ const bin = join(root, 'notifier-runtime')
33
+ writeFileSync(bin, '#!/bin/sh\nexec sleep 30\n', { mode: 0o755 })
34
+
35
+ const spec: LaunchSpec = {
36
+ personality: 'sockt',
37
+ runtime: 'notifier',
38
+ cwd: root,
39
+ identity: 'notifier-sockt',
40
+ socketPath: sock,
41
+ intelligence: 'absent',
42
+ }
43
+ const cfg: LaunchConfig = {
44
+ claudeBin: 'claude',
45
+ codexBin: 'codex',
46
+ notifierBin: bin,
47
+ sockDir,
48
+ bootDeadlineSecs: 1,
49
+ readyGateSecs: 1,
50
+ maxAgeSecs: 0,
51
+ logDir: join(root, 'logs'),
52
+ alwaysOn: true,
53
+ }
54
+
55
+ try {
56
+ // The regression assertion: before the fix the dir was NOT created and
57
+ // `tmux new-session -S <sock>` failed silently; now launch mkdir's it. (We assert
58
+ // dir creation, not the READY outcome — whether the session reaches READY depends
59
+ // on tmux + the test-runner env, which is orthogonal to this fix.)
60
+ await launch(spec, notifierAdapter, '', cfg)
61
+ expect(existsSync(sockDir)).toBe(true)
62
+ } finally {
63
+ spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
64
+ }
65
+ })
66
+
67
+ test('the fix is unconditional dirname(sock) creation (documents the parent)', () => {
68
+ expect(dirname('/a/b/c/tmux-iap-notifier-x.sock')).toBe('/a/b/c')
69
+ })
70
+ })