@agfpd/iapeer 0.4.7 → 0.4.9
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/package.json +1 -1
- package/src/cli/index.ts +13 -80
- package/src/cli/listTui.ts +4 -14
- package/src/launch/cmdlog.ts +7 -110
- package/src/launch/index.ts +14 -314
- package/src/launch/launchdRun.ts +9 -52
- package/src/launch/ptyHost.ts +0 -12
- package/src/launch/readyGateModel.ts +9 -80
- package/src/lifecycle/index.ts +25 -101
- package/src/supervisor/daemon.ts +35 -4
- package/src/{shadow → supervisor}/render.ts +10 -10
- package/src/transport/index.ts +41 -400
- package/src/update/index.ts +0 -9
- package/src/launch/canary.ts +0 -239
- package/src/shadow/gate.ts +0 -396
- package/src/shadow/index.ts +0 -174
- package/src/shadow/install.ts +0 -256
package/src/shadow/index.ts
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
// Read-only fidelity OBSERVER for the tmux→pty migration (prod burn-in track).
|
|
2
|
-
//
|
|
3
|
-
// For each warm claude/codex peer it builds an @xterm/headless model from the daemon's
|
|
4
|
-
// EXISTING pipe-pane log (`<identity>.log` under cfg.logDir) — the same raw child bytes
|
|
5
|
-
// tmux's emulator gets — and compares the PTY-model verdicts (composer-occupancy / ready-gate
|
|
6
|
-
// / liveness) to the live AUTHORITATIVE tmux verdicts, logging divergences + rolling fidelity
|
|
7
|
-
// to `<eventLogDir>/shadow-fidelity.{jsonl,json}`.
|
|
8
|
-
//
|
|
9
|
-
// STRICTLY READ-ONLY: it tails log FILES and runs read-only tmux (`capture-pane`/`display`/
|
|
10
|
-
// `has-session`). It NEVER delivers, sends keys, spawns, or touches delivery/lifecycle/
|
|
11
|
-
// registry/launch. Nothing in the delivery/lifecycle hot paths imports this module — it runs
|
|
12
|
-
// ONLY via the `iapeer shadow` CLI verb (dynamic-imported, so @xterm is never loaded in the
|
|
13
|
-
// daemon). With it off, the daemon's behavior is byte-identical. This is the prod burn-in:
|
|
14
|
-
// the supervisor's model proven alongside tmux on real traffic BEFORE any delivery cutover.
|
|
15
|
-
import { Terminal } from '@xterm/headless'
|
|
16
|
-
import { openSync, closeSync, fstatSync, readSync, existsSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
17
|
-
import { join } from 'node:path'
|
|
18
|
-
import { tmux, composerCaptureHasHumanInput, listOnlinePeers } from '../transport/index.ts'
|
|
19
|
-
import { getAdapter } from '../launch/index.ts'
|
|
20
|
-
import { ptyHostEnabled } from '../launch/ptyHost.ts' // spawn-flip Ф0b-3: the observer skips supervisor-hosted peers
|
|
21
|
-
import { buildSocketPath, buildProcessAddress } from '../core/socket.ts'
|
|
22
|
-
import type { Runtime } from '../core/constants.ts'
|
|
23
|
-
import { GateTracker, classifyDirection, evaluateReadyGateFlipEligibility, VERDICTS } from './gate.ts'
|
|
24
|
-
// modelToPlainText lives in the leaf render.ts (shared with readyGateModel + the supervisor
|
|
25
|
-
// boot-driver); re-exported here so existing importers of './index.ts' keep resolving.
|
|
26
|
-
import { composerOccupancyFromModel, modelToPlainText } from './render.ts'
|
|
27
|
-
export { composerOccupancyFromModel, modelToPlainText }
|
|
28
|
-
|
|
29
|
-
const SEED_BYTES = 4 * 1024 * 1024
|
|
30
|
-
const pidAlive = (pid: number): boolean => {
|
|
31
|
-
try { process.kill(pid, 0); return true } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM' }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface Obs { addr: string; sock: string; runtime: Runtime; cols: number; rows: number; term: Terminal; fd: number; offset: number }
|
|
35
|
-
|
|
36
|
-
export interface ShadowOptions {
|
|
37
|
-
logDir: string // daemon pane-logs live here (<identity>.log)
|
|
38
|
-
eventLogDir: string // shadow-fidelity.{jsonl,json} written here
|
|
39
|
-
sockDir: string
|
|
40
|
-
intervalMs?: number
|
|
41
|
-
maxMinutes?: number // 0/undefined + once=false → run until <eventLogDir>/shadow-fidelity.STOP
|
|
42
|
-
once?: boolean
|
|
43
|
-
log?: (s: string) => void
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Feed the pane-log delta into the model; resolves with the byte count appended this tick
|
|
47
|
-
* (drives the coverage state classifier: >0 ⇒ the model is advancing, not idle). */
|
|
48
|
-
function feed(o: Obs): Promise<number> {
|
|
49
|
-
const sz = fstatSync(o.fd).size
|
|
50
|
-
if (sz <= o.offset) return Promise.resolve(0)
|
|
51
|
-
const buf = Buffer.alloc(sz - o.offset)
|
|
52
|
-
readSync(o.fd, buf, 0, buf.length, o.offset)
|
|
53
|
-
o.offset = sz
|
|
54
|
-
return new Promise(res => o.term.write(buf, () => res(buf.length)))
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function enumerate(models: Map<string, Obs>, opts: ShadowOptions, tracker?: GateTracker): void {
|
|
58
|
-
let peers: { runtime: Runtime; personality: string }[] = []
|
|
59
|
-
try { peers = listOnlinePeers(opts.sockDir).filter(p => p.runtime === 'claude' || p.runtime === 'codex') } catch { return }
|
|
60
|
-
const live = new Set<string>()
|
|
61
|
-
for (const p of peers) {
|
|
62
|
-
const addr = buildProcessAddress(p.runtime, p.personality)
|
|
63
|
-
// SPAWN-FLIP Ф0b-3: NEVER measure a supervisor-HOSTED peer. The observer's premise is pty-model vs
|
|
64
|
-
// LIVE TMUX, so it only compares peers that are actually on tmux. pty-hosting is now the DEFAULT, so
|
|
65
|
-
// ptyHostEnabled is true for a normal peer (hosted → skip) and false only for an explicit `.no-pty-host`
|
|
66
|
-
// opt-OUT peer (tmux → compared). Skip BEFORE `live` — a peer that flips falls out of `live` and is
|
|
67
|
-
// reaped. (Post-migration the whole fleet is pty-default, so this skips everything and the observer is
|
|
68
|
-
// effectively retired until/unless a peer opts back to tmux.)
|
|
69
|
-
if (ptyHostEnabled(opts.logDir, addr)) continue
|
|
70
|
-
live.add(addr)
|
|
71
|
-
if (models.has(addr)) continue
|
|
72
|
-
try {
|
|
73
|
-
const sock = buildSocketPath(p.runtime, p.personality, opts.sockDir)
|
|
74
|
-
const log = join(opts.logDir, `${addr}.log`); if (!existsSync(log)) continue
|
|
75
|
-
const geo = tmux(sock, 'display', '-p', '-t', addr, '#{pane_width}x#{pane_height}'); if (!geo.ok) continue
|
|
76
|
-
const [cols, rows] = geo.out.trim().split('x').map(Number); if (!cols || !rows) continue
|
|
77
|
-
const term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 3000 })
|
|
78
|
-
const fd = openSync(log, 'r'); const offset = Math.max(0, fstatSync(fd).size - SEED_BYTES)
|
|
79
|
-
models.set(addr, { addr, sock, runtime: p.runtime, cols, rows, term, fd, offset })
|
|
80
|
-
} catch { /* a peer racing spawn/teardown — skip this round */ }
|
|
81
|
-
}
|
|
82
|
-
for (const [addr, o] of models) if (!live.has(addr)) { try { closeSync(o.fd) } catch { /* */ }; tracker?.peerGone(addr); models.delete(addr) }
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Run the read-only fidelity observer. Resolves when once/maxMinutes/STOP completes. */
|
|
86
|
-
export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
|
|
87
|
-
mkdirSync(opts.eventLogDir, { recursive: true })
|
|
88
|
-
const jsonl = join(opts.eventLogDir, 'shadow-fidelity.jsonl')
|
|
89
|
-
const statsFile = join(opts.eventLogDir, 'shadow-fidelity.json')
|
|
90
|
-
const stopFile = join(opts.eventLogDir, 'shadow-fidelity.STOP')
|
|
91
|
-
const interval = opts.intervalMs ?? 5000
|
|
92
|
-
const log = opts.log ?? (() => { /* */ })
|
|
93
|
-
const models = new Map<string, Obs>()
|
|
94
|
-
const tracker = new GateTracker()
|
|
95
|
-
const startedISO = new Date().toISOString(), t0 = Date.now()
|
|
96
|
-
enumerate(models, opts, tracker)
|
|
97
|
-
for (const o of models.values()) await feed(o) // seed each model
|
|
98
|
-
log(`shadow-fidelity: ${models.size} warm peers, interval ${interval}ms\n`)
|
|
99
|
-
let i = 0
|
|
100
|
-
for (;;) {
|
|
101
|
-
if (opts.maxMinutes && Date.now() - t0 >= opts.maxMinutes * 60000) break
|
|
102
|
-
if (existsSync(stopFile)) break
|
|
103
|
-
// Re-enroll every ~3 ticks (~15s), not 15 (~75s): a freshly-woken peer must be enrolled
|
|
104
|
-
// and sampled DURING its not-ready phase for a readiness transition (the center cell) to
|
|
105
|
-
// register — a 75s enroll-lag could first-sight it already-ready and miss the transition.
|
|
106
|
-
if (i > 0 && i % 3 === 0) enumerate(models, opts, tracker)
|
|
107
|
-
tracker.tickStart(models.size)
|
|
108
|
-
for (const o of models.values()) {
|
|
109
|
-
try {
|
|
110
|
-
const bytesFed = await feed(o)
|
|
111
|
-
const adapter = getAdapter(o.runtime)
|
|
112
|
-
const capE = tmux(o.sock, 'capture-pane', '-e', '-p', '-t', o.addr); if (!capE.ok) continue
|
|
113
|
-
const occTmux = composerCaptureHasHumanInput(capE.out, adapter.deliveryMarkers)
|
|
114
|
-
const occPty = composerOccupancyFromModel(o.term, o.cols, o.rows, o.runtime)
|
|
115
|
-
const capP = tmux(o.sock, 'capture-pane', '-p', '-t', o.addr)
|
|
116
|
-
const rgTmux = capP.ok ? adapter.isInputReady(capP.out) : null
|
|
117
|
-
const rgPty = adapter.isInputReady(modelToPlainText(o.term, o.cols, o.rows))
|
|
118
|
-
const livTmux = tmux(o.sock, 'has-session', '-t', o.addr).ok
|
|
119
|
-
const ppid = tmux(o.sock, 'display', '-p', '-t', o.addr, '#{pane_pid}')
|
|
120
|
-
const pid = ppid.ok ? Number(ppid.out.trim()) : NaN
|
|
121
|
-
const livPty = Number.isFinite(pid) ? pidAlive(pid) : false
|
|
122
|
-
const hasHistory = o.term.buffer.active.baseY > 0
|
|
123
|
-
|
|
124
|
-
// Feed the gate tracker — it scores DIRECTION (behind=hard / ahead=benign-if-boot-self-heal)
|
|
125
|
-
// and accumulates the COVERAGE matrix. The boolean booleans never leave this call.
|
|
126
|
-
tracker.sample(i, {
|
|
127
|
-
peer: o.addr, runtime: o.runtime,
|
|
128
|
-
occupancy: { pty: occPty, tmux: occTmux },
|
|
129
|
-
readyGate: { pty: rgPty, tmux: rgTmux },
|
|
130
|
-
liveness: { pty: livPty, tmux: livTmux },
|
|
131
|
-
bytesFed, hasHistory,
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
// DIRECTION-AWARE per-event divergence trail (gate scoring lives in the tracker; this is
|
|
135
|
-
// the forensic log). Occupancy carries the raw capture + model frames for root-cause.
|
|
136
|
-
const occDir = classifyDirection(occPty, occTmux)
|
|
137
|
-
const rgDir = rgTmux === null ? 'agree' : classifyDirection(rgPty, rgTmux)
|
|
138
|
-
const livDir = classifyDirection(livPty, livTmux)
|
|
139
|
-
if (occDir !== 'agree' || rgDir !== 'agree' || livDir !== 'agree') {
|
|
140
|
-
appendFileSync(jsonl, JSON.stringify({
|
|
141
|
-
ts: new Date().toISOString(), peer: o.addr, geom: `${o.cols}x${o.rows}`, hasHistory,
|
|
142
|
-
occupancy: { pty: occPty, tmux: occTmux, dir: occDir },
|
|
143
|
-
readyGate: { pty: rgPty, tmux: rgTmux, dir: rgDir },
|
|
144
|
-
liveness: { pty: livPty, tmux: livTmux, dir: livDir },
|
|
145
|
-
...(occDir !== 'agree' ? { capE: capE.out.slice(-2500), model: modelToPlainText(o.term, o.cols, o.rows).slice(-2500) } : {}),
|
|
146
|
-
}) + '\n')
|
|
147
|
-
log(`DIVERGE ${o.addr} occ=${occDir} rg=${rgDir} liv=${livDir}\n`)
|
|
148
|
-
}
|
|
149
|
-
} catch { /* an observer error must NEVER escape — it is a passive sidecar */ }
|
|
150
|
-
}
|
|
151
|
-
i++
|
|
152
|
-
const gs = tracker.summary()
|
|
153
|
-
const totalSamples = VERDICTS.reduce((n, v) => n + gs.verdicts[v].samples, 0)
|
|
154
|
-
writeFileSync(statsFile, JSON.stringify({
|
|
155
|
-
startedISO, lastISO: new Date().toISOString(), elapsedMin: +((Date.now() - t0) / 60000).toFixed(1),
|
|
156
|
-
peers: models.size, samples: totalSamples,
|
|
157
|
-
// Gate criterion: clean iff zero hard divergences across all verdicts. Hard =
|
|
158
|
-
// every pty-behind + non-benign pty-ahead (steady-state or non-converging). Benign =
|
|
159
|
-
// boot-window ahead that self-heals within budget. Coverage proves the matrix was traversed.
|
|
160
|
-
gateClean: VERDICTS.every(v => gs.verdicts[v].gateClean),
|
|
161
|
-
hardTotal: gs.hardViolations.length,
|
|
162
|
-
// Ready-gate FLIP eligibility (first-flip verdict) self-reported: NECESSARY input, not an
|
|
163
|
-
// auto-trigger — the flip stays an explicit decision. On !eligible, `reasons` say which cell
|
|
164
|
-
// is empty/dirty (actionable). composer-busy is excluded (orthogonal — gates the later occupancy flip).
|
|
165
|
-
readyGateFlip: evaluateReadyGateFlipEligibility(gs),
|
|
166
|
-
verdicts: gs.verdicts,
|
|
167
|
-
coverage: gs.coverage,
|
|
168
|
-
recentHard: gs.hardViolations.slice(-20),
|
|
169
|
-
}, null, 2))
|
|
170
|
-
if (opts.once) break
|
|
171
|
-
await new Promise(r => setTimeout(r, interval))
|
|
172
|
-
}
|
|
173
|
-
for (const o of models.values()) { try { closeSync(o.fd) } catch { /* */ } }
|
|
174
|
-
}
|
package/src/shadow/install.ts
DELETED
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
// Code-managed install / teardown / update-recycle for the read-only tmux→pty fidelity
|
|
2
|
-
// BURN-IN job (`iapeer shadow`). The OBSERVER itself is src/shadow/index.ts (it loads
|
|
3
|
-
// @xterm/headless); THIS module is PURE launchd plumbing — it imports NO @xterm — so the
|
|
4
|
-
// deploy path (update/rollback) and the CLI can install/recycle the job without pulling the
|
|
5
|
-
// terminal emulator into their import graph (the daemon hot path stays @xterm-free).
|
|
6
|
-
//
|
|
7
|
-
// The job is a foundation-OWNED, always-on launchd LaunchAgent (com.iapeer.shadow-fidelity)
|
|
8
|
-
// running the INSTALLED binary `iapeer shadow`. It is NOT a registry peer — it receives no
|
|
9
|
-
// IAP and has no peer profile — so recycleFoundationOwnedInfraJobs' registry SCAN cannot see
|
|
10
|
-
// it; cycleShadowJob recycles it by LABEL instead. It carries the same ownership sentinel as
|
|
11
|
-
// every foundation plist, so the collision guard (install) and the recycle guard both
|
|
12
|
-
// recognize it as ours. KeepAlive owns its lifecycle; stop it with `iapeer shadow-uninstall`
|
|
13
|
-
// (bootout + rm), NEVER the STOP sentinel file (KeepAlive would just respawn straight past it).
|
|
14
|
-
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
16
|
-
import { homedir } from 'node:os'
|
|
17
|
-
import { join } from 'node:path'
|
|
18
|
-
import { spawnSync } from 'node:child_process'
|
|
19
|
-
import { IAPEER_DIR } from '../core/constants.ts'
|
|
20
|
-
import { IapError } from '../core/errors.ts'
|
|
21
|
-
import { iapeerBinPath } from '../install/index.ts'
|
|
22
|
-
import { pluginLogsDir } from '../storage/index.ts'
|
|
23
|
-
import {
|
|
24
|
-
cycleLaunchdJob,
|
|
25
|
-
isFoundationOwnedPlist,
|
|
26
|
-
launchAgentsDir,
|
|
27
|
-
launchctlBootstrap,
|
|
28
|
-
launchdLabel,
|
|
29
|
-
renderLaunchdPlist,
|
|
30
|
-
type LaunchdJobCycleResult,
|
|
31
|
-
type LaunchdPlistSpec,
|
|
32
|
-
} from '../launch/launchd.ts'
|
|
33
|
-
|
|
34
|
-
/** The burn-in job's personality stem → label com.iapeer.shadow-fidelity (the running job). */
|
|
35
|
-
export const SHADOW_PERSONALITY = 'shadow-fidelity'
|
|
36
|
-
/** com.iapeer.shadow-fidelity — keyed through launchdLabel so the scheme has ONE source. */
|
|
37
|
-
export const SHADOW_PLIST_LABEL = launchdLabel(SHADOW_PERSONALITY)
|
|
38
|
-
|
|
39
|
-
export function shadowPlistPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
40
|
-
return join(launchAgentsDir(env), `${SHADOW_PLIST_LABEL}.plist`)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** The STOP sentinel the observer polls (under <eventLogDir>=~/.iapeer/logs/iapeer). Under
|
|
44
|
-
* KeepAlive a STALE one would respawn the observer straight into an immediate exit → a
|
|
45
|
-
* throttle-storm, so installShadowJob clears it before (re)loading. */
|
|
46
|
-
function shadowStopFile(env: NodeJS.ProcessEnv = process.env): string {
|
|
47
|
-
return join(pluginLogsDir('iapeer', { env }), 'shadow-fidelity.STOP')
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function inSandbox(env: NodeJS.ProcessEnv): boolean {
|
|
51
|
-
return env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1'
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Resolve the gui-domain uid, or null when `id -u` is non-numeric (never assume 0). */
|
|
55
|
-
function uidOrNull(): string | null {
|
|
56
|
-
const u = (spawnSync('id', ['-u'], { encoding: 'utf8' }).stdout ?? '').trim()
|
|
57
|
-
return /^\d+$/.test(u) ? u : null
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Is com.iapeer.shadow-fidelity currently loaded in the gui domain? */
|
|
61
|
-
function isShadowLoaded(): boolean {
|
|
62
|
-
const uid = uidOrNull()
|
|
63
|
-
if (!uid) return false
|
|
64
|
-
return spawnSync('launchctl', ['print', `gui/${uid}/${SHADOW_PLIST_LABEL}`], { stdio: 'ignore' }).status === 0
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Build the burn-in job's launchd plist spec (PURE — render/lint-testable). Runs the
|
|
69
|
-
* INSTALLED `iapeer shadow` (prod decoupled from the src tree). The launchd-minimal PATH
|
|
70
|
-
* MUST include /opt/homebrew/bin: the observer shells READ-ONLY tmux (capture-pane /
|
|
71
|
-
* has-session / display), and tmux lives there — a bare PATH would make every tmux probe
|
|
72
|
-
* fail (no peers enumerated). Deterministic (curated PATH, not env.PATH) so the rendered
|
|
73
|
-
* plist is byte-stable across deploys → installShadowPlist's write-if-changed stays a no-op
|
|
74
|
-
* when nothing actually changed (no Background Task Management notification spam).
|
|
75
|
-
*/
|
|
76
|
-
export function buildShadowPlistSpec(env: NodeJS.ProcessEnv = process.env): LaunchdPlistSpec {
|
|
77
|
-
const home = env.HOME?.trim() || homedir()
|
|
78
|
-
const defaultPath = `${home}/.local/bin:${home}/.bun/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`
|
|
79
|
-
const logDir = pluginLogsDir('iapeer', { env })
|
|
80
|
-
const environment: Record<string, string> = { PATH: env.IAPEER_SHADOW_PATH?.trim() || defaultPath }
|
|
81
|
-
// Sandbox/test parity (mirror of installDaemonPlist / installAlwaysOnPlist): bake the
|
|
82
|
-
// non-default root overrides so a SANDBOXED install resolves the SAME isolated tree the
|
|
83
|
-
// observer reads. In production these are unset → nothing baked, real ~/.iapeer + /tmp used.
|
|
84
|
-
for (const key of ['IAPEER_ROOT', 'IAPEER_SOCK_DIR', 'IAPEER_LAUNCHAGENTS_DIR'] as const) {
|
|
85
|
-
if (env[key]?.trim()) environment[key] = env[key]!.trim()
|
|
86
|
-
}
|
|
87
|
-
return {
|
|
88
|
-
label: SHADOW_PLIST_LABEL,
|
|
89
|
-
programArguments: [iapeerBinPath(env), 'shadow'],
|
|
90
|
-
workingDirectory: home, // cwd-agnostic (the observer resolves paths from IAPEER_ROOT/cfg)
|
|
91
|
-
environment,
|
|
92
|
-
stdoutPath: join(logDir, 'shadow-fidelity.launchd-stdout.log'),
|
|
93
|
-
stderrPath: join(logDir, 'shadow-fidelity.launchd-stderr.log'),
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface InstallShadowPlistResult {
|
|
98
|
-
path: string
|
|
99
|
-
/** false when the on-disk plist already matched byte-for-byte → NO write (no BTM spam). */
|
|
100
|
-
changed: boolean
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Write the burn-in plist (file only — does NOT load it). IDEMPOTENT BY CONTENT
|
|
105
|
-
* (write-if-changed, mirror of installDaemonPlist: a byte-identical rewrite trips the owner's
|
|
106
|
-
* Background Task Management notification for zero benefit). Collision guard: refuses to
|
|
107
|
-
* overwrite a com.iapeer.shadow-fidelity.plist that lacks the ownership sentinel (a foreign
|
|
108
|
-
* manager owns it) — the guard fires FIRST, before any mkdir/write, so a refusal leaves the
|
|
109
|
-
* filesystem untouched.
|
|
110
|
-
*/
|
|
111
|
-
export function installShadowPlist(env: NodeJS.ProcessEnv = process.env): InstallShadowPlistResult {
|
|
112
|
-
const path = shadowPlistPath(env)
|
|
113
|
-
if (existsSync(path) && !isFoundationOwnedPlist(path)) {
|
|
114
|
-
throw new IapError(
|
|
115
|
-
`refusing to overwrite ${path}: ${SHADOW_PLIST_LABEL} exists but is not foundation-managed ` +
|
|
116
|
-
`(no ownership sentinel) — another manager owns it`,
|
|
117
|
-
)
|
|
118
|
-
}
|
|
119
|
-
const rendered = renderLaunchdPlist(buildShadowPlistSpec(env))
|
|
120
|
-
mkdirSync(launchAgentsDir(env), { recursive: true })
|
|
121
|
-
// Never mkdir the REAL ~/.iapeer log dir under a sandbox that forgot IAPEER_ROOT (mirror
|
|
122
|
-
// installAlwaysOnPlist's guard); a real run (no sandbox flag) always makes it.
|
|
123
|
-
const logDir = pluginLogsDir('iapeer', { env })
|
|
124
|
-
const realRoot = join(homedir(), IAPEER_DIR)
|
|
125
|
-
if (!(inSandbox(env) && logDir.startsWith(`${realRoot}/`))) {
|
|
126
|
-
mkdirSync(logDir, { recursive: true, mode: 0o700 })
|
|
127
|
-
}
|
|
128
|
-
let existing: string | null = null
|
|
129
|
-
try {
|
|
130
|
-
existing = readFileSync(path, 'utf8')
|
|
131
|
-
} catch {
|
|
132
|
-
existing = null
|
|
133
|
-
}
|
|
134
|
-
if (existing === rendered) return { path, changed: false }
|
|
135
|
-
writeFileSync(path, rendered, { mode: 0o644 })
|
|
136
|
-
return { path, changed: true }
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export type ShadowJobAction =
|
|
140
|
-
| 'loaded' // bootstrapped now (was not loaded)
|
|
141
|
-
| 'reloaded' // plist changed on an already-loaded job → bootout+bootstrap applied the new one
|
|
142
|
-
| 'already-loaded' // loaded and the plist did not change → left running untouched
|
|
143
|
-
| 'refused-foreign' // a non-foundation plist sits at the label → never loaded
|
|
144
|
-
| 'failed' // launchctl bootstrap/cycle failed (detail carries the rescue recipe)
|
|
145
|
-
| 'skipped-sandbox' // IAPEER_TEST_SANDBOX=1 → never touch real launchd
|
|
146
|
-
|
|
147
|
-
export interface InstallShadowJobResult extends InstallShadowPlistResult {
|
|
148
|
-
action: ShadowJobAction
|
|
149
|
-
detail?: string
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Install the burn-in plist AND ensure the job is live: write-if-changed, clear a stale STOP
|
|
154
|
-
* sentinel, then load it (or RELOAD via bootout+bootstrap when the plist content changed on
|
|
155
|
-
* an already-loaded job — bootstrap alone is idempotent and would NOT pick up a changed
|
|
156
|
-
* plist). Idempotent: a repeat call on an unchanged, loaded job is a no-op ('already-loaded').
|
|
157
|
-
* Sandbox-safe: under IAPEER_TEST_SANDBOX it writes the (overridden-dir) plist FILE but never
|
|
158
|
-
* calls launchctl ('skipped-sandbox'). This is the code-managed replacement for a hand-written
|
|
159
|
-
* burn-in plist — reproducible, ownership-sentinel-guarded, fleet-deployable.
|
|
160
|
-
*/
|
|
161
|
-
export function installShadowJob(env: NodeJS.ProcessEnv = process.env): InstallShadowJobResult {
|
|
162
|
-
const { path, changed } = installShadowPlist(env)
|
|
163
|
-
if (inSandbox(env)) return { path, changed, action: 'skipped-sandbox' }
|
|
164
|
-
// Clear a STALE STOP sentinel BEFORE (re)loading — else KeepAlive respawns the observer
|
|
165
|
-
// into an instant exit (it polls the STOP file) → 10s-throttled respawn storm.
|
|
166
|
-
try {
|
|
167
|
-
unlinkSync(shadowStopFile(env))
|
|
168
|
-
} catch {
|
|
169
|
-
/* none present */
|
|
170
|
-
}
|
|
171
|
-
if (isShadowLoaded()) {
|
|
172
|
-
if (!changed) return { path, changed, action: 'already-loaded' }
|
|
173
|
-
const c = cycleLaunchdJob(SHADOW_PLIST_LABEL, path, env)
|
|
174
|
-
return c.state === 'restarted'
|
|
175
|
-
? { path, changed, action: 'reloaded' }
|
|
176
|
-
: { path, changed, action: 'failed', detail: c.detail }
|
|
177
|
-
}
|
|
178
|
-
const b = launchctlBootstrap(SHADOW_PERSONALITY, path, env)
|
|
179
|
-
const action: ShadowJobAction =
|
|
180
|
-
b.state === 'loaded'
|
|
181
|
-
? 'loaded'
|
|
182
|
-
: b.state === 'already-loaded'
|
|
183
|
-
? 'already-loaded'
|
|
184
|
-
: b.state === 'refused-foreign'
|
|
185
|
-
? 'refused-foreign'
|
|
186
|
-
: b.state === 'skipped-sandbox'
|
|
187
|
-
? 'skipped-sandbox'
|
|
188
|
-
: 'failed'
|
|
189
|
-
return { path, changed, action, detail: b.detail }
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Recycle the burn-in job by LABEL after a binary swap (deploy path). The plist runs the
|
|
194
|
-
* just-replaced `iapeer shadow` off the same binary, so the SAME LWCR managed-launch-constraint
|
|
195
|
-
* bomb that bit the daemon (EX_CONFIG(78) on a KeepAlive respawn into the stale constraint)
|
|
196
|
-
* applies here too — only a full bootout→bootstrap clears it. It is NOT a registry peer, so
|
|
197
|
-
* recycleFoundationOwnedInfraJobs' registry scan misses it; this cycles it directly. Only
|
|
198
|
-
* touches a LOADED, foundation-owned job: absent / foreign / not-loaded → null (no-op).
|
|
199
|
-
* Sandbox-safe (cycleLaunchdJob returns skipped-sandbox under the test flag).
|
|
200
|
-
*/
|
|
201
|
-
export function cycleShadowJob(env: NodeJS.ProcessEnv = process.env): LaunchdJobCycleResult | null {
|
|
202
|
-
const path = shadowPlistPath(env)
|
|
203
|
-
if (!existsSync(path) || !isFoundationOwnedPlist(path)) return null
|
|
204
|
-
return cycleLaunchdJob(SHADOW_PLIST_LABEL, path, env)
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export type ShadowUninstallAction =
|
|
208
|
-
| 'booted-out' // job was loaded → booted out, plist removed
|
|
209
|
-
| 'removed-not-loaded' // plist removed, job was not loaded
|
|
210
|
-
| 'absent' // nothing to do (no plist)
|
|
211
|
-
| 'refused-foreign' // the plist is not foundation-owned → left untouched
|
|
212
|
-
| 'skipped-sandbox'
|
|
213
|
-
| 'failed'
|
|
214
|
-
|
|
215
|
-
export interface UninstallShadowJobResult {
|
|
216
|
-
path: string
|
|
217
|
-
action: ShadowUninstallAction
|
|
218
|
-
detail?: string
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Stop and remove the burn-in job — the CORRECT way to halt it (the STOP sentinel would be
|
|
223
|
-
* respawned past by KeepAlive): bootout the loaded job, then delete the plist. Refuses a
|
|
224
|
-
* non-foundation plist (ownership guard). Sandbox-safe. Reversibility for the install verb.
|
|
225
|
-
*/
|
|
226
|
-
export function uninstallShadowJob(env: NodeJS.ProcessEnv = process.env): UninstallShadowJobResult {
|
|
227
|
-
const path = shadowPlistPath(env)
|
|
228
|
-
if (!existsSync(path)) return { path, action: 'absent' }
|
|
229
|
-
if (!isFoundationOwnedPlist(path)) {
|
|
230
|
-
return { path, action: 'refused-foreign', detail: `${path} is not foundation-owned — not booting it out` }
|
|
231
|
-
}
|
|
232
|
-
if (inSandbox(env)) return { path, action: 'skipped-sandbox' }
|
|
233
|
-
let action: ShadowUninstallAction = 'removed-not-loaded'
|
|
234
|
-
if (isShadowLoaded()) {
|
|
235
|
-
const uid = uidOrNull()
|
|
236
|
-
const r = uid
|
|
237
|
-
? spawnSync('launchctl', ['bootout', `gui/${uid}/${SHADOW_PLIST_LABEL}`], { encoding: 'utf8' })
|
|
238
|
-
: { status: 1, stderr: 'cannot resolve uid' }
|
|
239
|
-
if (r.status !== 0 && isShadowLoaded()) {
|
|
240
|
-
return { path, action: 'failed', detail: `launchctl bootout failed: ${(r.stderr ?? '').trim() || `exit ${r.status}`}` }
|
|
241
|
-
}
|
|
242
|
-
action = 'booted-out'
|
|
243
|
-
}
|
|
244
|
-
// Also clear any STOP sentinel so a later reinstall starts clean.
|
|
245
|
-
try {
|
|
246
|
-
unlinkSync(shadowStopFile(env))
|
|
247
|
-
} catch {
|
|
248
|
-
/* none */
|
|
249
|
-
}
|
|
250
|
-
try {
|
|
251
|
-
rmSync(path)
|
|
252
|
-
} catch {
|
|
253
|
-
/* best-effort */
|
|
254
|
-
}
|
|
255
|
-
return { path, action }
|
|
256
|
-
}
|