@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,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, '&')
|
|
116
|
+
.replace(/</g, '<')
|
|
117
|
+
.replace(/>/g, '>')
|
|
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
|
+
})
|