@agfpd/iapeer 0.2.58 → 0.2.60
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/README.md +11 -0
- package/package.json +1 -1
- package/src/cli/index.ts +29 -0
- package/src/shadow/gate.test.ts +143 -0
- package/src/shadow/gate.ts +306 -0
- package/src/shadow/index.ts +47 -28
- package/src/shadow/install.test.ts +153 -0
- package/src/shadow/install.ts +256 -0
- package/src/update/index.ts +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# iapeer
|
|
2
|
+
|
|
3
|
+
Ядро мультиагентной системы на одном хосте: реестр пиров, единый демон-роутер для обмена сообщениями между ними и команды управления — создание, запуск, усыпление, перезапуск, подключение к внешним каналам. Пиром может быть AI-агент (Claude, Codex), человек (через Telegram) или служебный процесс; все адресуются одинаково через один протокол.
|
|
4
|
+
|
|
5
|
+
## Документация
|
|
6
|
+
|
|
7
|
+
Документация проекта — в [`docs/`](docs/README.md): продуктовая часть (что это и как пользоваться) и механика изнутри ([`docs/internals/`](docs/internals/README.md)). Этот репозиторий — кодовая реализация.
|
|
8
|
+
|
|
9
|
+
## Статус
|
|
10
|
+
|
|
11
|
+
Версия — **0.2.58**. Платформа — macOS/launchd.
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -937,6 +937,8 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
937
937
|
native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
|
|
938
938
|
trust-hooks <hooks.json> [--check] pre-seed codex hooks trust for a file-form hooks.json (no modal); --check = drift report
|
|
939
939
|
shadow [--once | --minutes <m>] [--interval <ms>] READ-ONLY tmux→pty fidelity observer (migration burn-in): compares pty-model verdicts vs live tmux on the warm fleet → ~/.iapeer/logs/iapeer/shadow-fidelity.json. Never delivers/mutates.
|
|
940
|
+
shadow-install install + load the always-on shadow burn-in launchd job (com.iapeer.shadow-fidelity); idempotent, re-cycled by update/rollback
|
|
941
|
+
shadow-uninstall bootout + remove the shadow burn-in launchd job (the correct way to stop it; STOP file is respawned past by KeepAlive)
|
|
940
942
|
`
|
|
941
943
|
|
|
942
944
|
export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
@@ -1772,6 +1774,33 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1772
1774
|
out(`shadow-fidelity → ${cfg.eventLogDir}/shadow-fidelity.json\n`)
|
|
1773
1775
|
return 0
|
|
1774
1776
|
}
|
|
1777
|
+
case 'shadow-install': {
|
|
1778
|
+
// Code-managed install of the always-on shadow burn-in launchd job
|
|
1779
|
+
// (com.iapeer.shadow-fidelity) — reproducible, ownership-sentinel-guarded, and
|
|
1780
|
+
// re-cycled automatically by `iapeer update` / `rollback`. Replaces a hand-written
|
|
1781
|
+
// plist. Idempotent (no-op on an unchanged, loaded job). install.ts carries NO @xterm
|
|
1782
|
+
// — dynamic-imported anyway to keep the CLI's common path light.
|
|
1783
|
+
const { installShadowJob } = await import('../shadow/install.ts')
|
|
1784
|
+
const r = installShadowJob(env)
|
|
1785
|
+
if (r.action === 'failed' || r.action === 'refused-foreign') {
|
|
1786
|
+
errOut(`shadow-install: ${r.action}${r.detail ? ` — ${r.detail}` : ''}\n plist: ${r.path}\n`)
|
|
1787
|
+
return 1
|
|
1788
|
+
}
|
|
1789
|
+
out(`shadow burn-in job ${r.action} (plist ${r.changed ? 'written' : 'unchanged'}): ${r.path}\n`)
|
|
1790
|
+
return 0
|
|
1791
|
+
}
|
|
1792
|
+
case 'shadow-uninstall': {
|
|
1793
|
+
// Stop + remove the burn-in job (bootout + rm) — the CORRECT halt (KeepAlive would
|
|
1794
|
+
// respawn past the STOP sentinel). Refuses a non-foundation plist (ownership guard).
|
|
1795
|
+
const { uninstallShadowJob } = await import('../shadow/install.ts')
|
|
1796
|
+
const r = uninstallShadowJob(env)
|
|
1797
|
+
if (r.action === 'failed' || r.action === 'refused-foreign') {
|
|
1798
|
+
errOut(`shadow-uninstall: ${r.action}${r.detail ? ` — ${r.detail}` : ''}\n plist: ${r.path}\n`)
|
|
1799
|
+
return 1
|
|
1800
|
+
}
|
|
1801
|
+
out(`shadow burn-in job ${r.action}: ${r.path}\n`)
|
|
1802
|
+
return 0
|
|
1803
|
+
}
|
|
1775
1804
|
default: {
|
|
1776
1805
|
// `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh.
|
|
1777
1806
|
if (verb && isRuntime(verb)) {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
GateTracker,
|
|
4
|
+
classifyDirection,
|
|
5
|
+
classifySampleState,
|
|
6
|
+
type PeerSample,
|
|
7
|
+
} from './gate.ts'
|
|
8
|
+
|
|
9
|
+
// SYNTHETIC ACCEPTANCE (boris's Block-1 gate): prove the instrument classifies known-behind vs
|
|
10
|
+
// known-ahead and records coverage CORRECTLY — the live proof that it measures right before we
|
|
11
|
+
// trust any pty verdict for a flip. Pure: no tmux, no @xterm, booleans in, gate stats out.
|
|
12
|
+
|
|
13
|
+
const mkSample = (peer: string, runtime: string, over: Partial<PeerSample> = {}): PeerSample => ({
|
|
14
|
+
peer,
|
|
15
|
+
runtime,
|
|
16
|
+
occupancy: { pty: false, tmux: false },
|
|
17
|
+
readyGate: { pty: false, tmux: false },
|
|
18
|
+
liveness: { pty: true, tmux: true },
|
|
19
|
+
bytesFed: 0,
|
|
20
|
+
hasHistory: false,
|
|
21
|
+
...over,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('classifyDirection (tmux authoritative; true = active/ready/alive pole)', () => {
|
|
25
|
+
test('equal → agree', () => {
|
|
26
|
+
expect(classifyDirection(true, true)).toBe('agree')
|
|
27
|
+
expect(classifyDirection(false, false)).toBe('agree')
|
|
28
|
+
})
|
|
29
|
+
test('pty under-reports (false vs true) → pty-behind (the stale class)', () => {
|
|
30
|
+
expect(classifyDirection(false, true)).toBe('pty-behind')
|
|
31
|
+
})
|
|
32
|
+
test('pty over-reports (true vs false) → pty-ahead', () => {
|
|
33
|
+
expect(classifyDirection(true, false)).toBe('pty-ahead')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('classifySampleState (coverage regimes — idle must be distinguishable, false-green lesson)', () => {
|
|
38
|
+
test('tmux occupancy → composer-busy (regardless of bytes)', () => {
|
|
39
|
+
expect(classifySampleState(true, 0)).toBe('composer-busy')
|
|
40
|
+
expect(classifySampleState(true, 999)).toBe('composer-busy')
|
|
41
|
+
})
|
|
42
|
+
test('no occupancy + output churn → model-advancing (non-trivial regime)', () => {
|
|
43
|
+
expect(classifySampleState(false, 4096)).toBe('model-advancing')
|
|
44
|
+
})
|
|
45
|
+
test('no occupancy + no churn → idle', () => {
|
|
46
|
+
expect(classifySampleState(false, 0)).toBe('idle')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('GateTracker — direction is scored hard vs benign per the adjudicated criterion', () => {
|
|
51
|
+
test('pty-BEHIND is ALWAYS hard (zero tolerance) and closes the verdict gate', () => {
|
|
52
|
+
const g = new GateTracker()
|
|
53
|
+
g.sample(0, mkSample('p', 'claude', { occupancy: { pty: false, tmux: true } }))
|
|
54
|
+
const s = g.summary()
|
|
55
|
+
expect(s.verdicts.occupancy.behind).toBe(1)
|
|
56
|
+
expect(s.verdicts.occupancy.gateClean).toBe(false)
|
|
57
|
+
expect(s.hardViolations.some(v => v.kind === 'behind' && v.verdict === 'occupancy')).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('boot-window pty-AHEAD that self-heals within budget → BENIGN (latency recorded)', () => {
|
|
61
|
+
const g = new GateTracker({ settleTicks: 3, convergeBudgetTicks: 3 })
|
|
62
|
+
// tick0: readyGate ahead (pty ready, tmux not) — peer just born → boot window
|
|
63
|
+
g.sample(0, mkSample('p', 'claude', { readyGate: { pty: true, tmux: false } }))
|
|
64
|
+
// tick1: readyGate agrees (both ready) → transient closes, latency 1, boot+within budget → benign
|
|
65
|
+
g.sample(1, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
|
|
66
|
+
const rg = g.summary().verdicts.readyGate
|
|
67
|
+
expect(rg.aheadBenign).toBe(1)
|
|
68
|
+
expect(rg.aheadHard).toBe(0)
|
|
69
|
+
expect(rg.gateClean).toBe(true)
|
|
70
|
+
expect(rg.selfHeal.count).toBe(1)
|
|
71
|
+
expect(rg.selfHeal.max).toBe(1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('READY-GATE non-converging pty-AHEAD is HARD, never benign (refinement #3: false-ready = msg-loss)', () => {
|
|
75
|
+
const g = new GateTracker({ settleTicks: 3, convergeBudgetTicks: 3 })
|
|
76
|
+
// hold readyGate ahead (pty ready, tmux NEVER ready → stays in boot window, never converges)
|
|
77
|
+
for (let t = 0; t <= 5; t++) g.sample(t, mkSample('p', 'codex', { readyGate: { pty: true, tmux: false } }))
|
|
78
|
+
const rg = g.summary().verdicts.readyGate
|
|
79
|
+
expect(rg.aheadHard).toBeGreaterThanOrEqual(1)
|
|
80
|
+
expect(rg.aheadBenign).toBe(0)
|
|
81
|
+
expect(rg.gateClean).toBe(false)
|
|
82
|
+
expect(g.summary().hardViolations.some(v => v.kind === 'ahead-nonconverged' && v.verdict === 'readyGate')).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('STEADY-STATE pty-AHEAD is HARD immediately (cannot be a boot self-heal)', () => {
|
|
86
|
+
const g = new GateTracker({ settleTicks: 2, convergeBudgetTicks: 3 })
|
|
87
|
+
// boot the peer: readyGate true for settleTicks
|
|
88
|
+
g.sample(0, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
|
|
89
|
+
g.sample(1, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } })) // bootDone now
|
|
90
|
+
// steady-state occupancy ahead → hard 'ahead-steady'
|
|
91
|
+
g.sample(2, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true }, occupancy: { pty: true, tmux: false } }))
|
|
92
|
+
const occ = g.summary().verdicts.occupancy
|
|
93
|
+
expect(occ.aheadHard).toBeGreaterThanOrEqual(1)
|
|
94
|
+
expect(g.summary().hardViolations.some(v => v.kind === 'ahead-steady' && v.verdict === 'occupancy')).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('full agreement → clean gate, zero hard violations', () => {
|
|
98
|
+
const g = new GateTracker()
|
|
99
|
+
for (let t = 0; t < 5; t++) g.sample(t, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
|
|
100
|
+
const s = g.summary()
|
|
101
|
+
expect(s.hardViolations).toHaveLength(0)
|
|
102
|
+
expect(s.verdicts.readyGate.gateClean).toBe(true)
|
|
103
|
+
expect(s.verdicts.occupancy.gateClean).toBe(true)
|
|
104
|
+
expect(s.verdicts.liveness.gateClean).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('GateTracker — coverage matrix (clean counts only over traversed cells)', () => {
|
|
109
|
+
test('births split fresh vs wake-resume by history-at-first-sight', () => {
|
|
110
|
+
const g = new GateTracker()
|
|
111
|
+
g.tickStart(1)
|
|
112
|
+
g.sample(0, mkSample('fresh-peer', 'claude', { hasHistory: false }))
|
|
113
|
+
g.tickStart(2)
|
|
114
|
+
g.sample(1, mkSample('woke-peer', 'codex', { hasHistory: true })) // history at first sight = wake-resume
|
|
115
|
+
const cov = g.summary().coverage
|
|
116
|
+
expect(cov.births['claude/fresh']).toBe(1)
|
|
117
|
+
expect(cov.births['codex/wake-resume']).toBe(1)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('a peer that died and reappeared is a restart', () => {
|
|
121
|
+
const g = new GateTracker()
|
|
122
|
+
g.sample(0, mkSample('p', 'claude'))
|
|
123
|
+
g.peerGone('p')
|
|
124
|
+
g.sample(5, mkSample('p', 'claude'))
|
|
125
|
+
expect(g.summary().coverage.restarts['claude']).toBe(1)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('state cells, fleet width, and history samples are recorded', () => {
|
|
129
|
+
const g = new GateTracker()
|
|
130
|
+
g.tickStart(3) // fleet width 3 this tick
|
|
131
|
+
g.sample(0, mkSample('p', 'claude', { occupancy: { pty: true, tmux: true } })) // composer-busy
|
|
132
|
+
g.sample(1, mkSample('p', 'claude', { bytesFed: 4096 })) // model-advancing
|
|
133
|
+
g.sample(2, mkSample('p', 'claude', { bytesFed: 0 })) // idle
|
|
134
|
+
g.sample(3, mkSample('h', 'codex', { hasHistory: true }))
|
|
135
|
+
const cov = g.summary().coverage
|
|
136
|
+
expect(cov.fleetWidthMax).toBe(3)
|
|
137
|
+
expect(cov.byRuntimeState['claude/composer-busy']).toBe(1)
|
|
138
|
+
expect(cov.byRuntimeState['claude/model-advancing']).toBe(1)
|
|
139
|
+
expect(cov.byRuntimeState['claude/idle']).toBe(1)
|
|
140
|
+
expect(cov.historySamples['codex']).toBe(1)
|
|
141
|
+
expect(cov.runtimes).toEqual(['claude', 'codex'])
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Gate-grade instrumentation for the tmux→pty fidelity burn-in (cutover Block 1).
|
|
2
|
+
//
|
|
3
|
+
// PURE classifiers + one stateful tracker, with NO tmux and NO @xterm import — the observer
|
|
4
|
+
// loop feeds it booleans, so all classification is hermetically unit-testable. That is exactly
|
|
5
|
+
// the synthetic acceptance boris requires: PROVE the instrument classifies known-behind vs
|
|
6
|
+
// known-ahead and records coverage correctly BEFORE we trust any pty verdict for a flip.
|
|
7
|
+
//
|
|
8
|
+
// It operationalizes the adjudicated gate criterion (NOT a flat sample count):
|
|
9
|
+
// • DIRECTION — pty-BEHIND (pty=False,tmux=True: a stale frame while live tmux moved on) is
|
|
10
|
+
// the reattach-stale class → ZERO tolerance, always hard. pty-AHEAD (pty=True,tmux=False)
|
|
11
|
+
// is benign ONLY when it (a) opened in the boot window, (b) self-heals within the converge
|
|
12
|
+
// budget, and (c) is not steady-state. Anything else → hard. For READY-GATE a non-converging
|
|
13
|
+
// ahead is a FALSE-ready (early-delivery → msg-loss class), so it is hard, never benign.
|
|
14
|
+
// • COVERAGE — which matrix cells the burn-in actually traversed {runtime × {idle,
|
|
15
|
+
// model-advancing, composer-busy}} + births (fresh vs wake-resume-with-history) + restarts +
|
|
16
|
+
// history-bearing samples + fleet width. "Clean" counts only over traversed cells (idle
|
|
17
|
+
// alone is a false green).
|
|
18
|
+
|
|
19
|
+
export type Verdict = 'occupancy' | 'readyGate' | 'liveness'
|
|
20
|
+
export const VERDICTS: Verdict[] = ['occupancy', 'readyGate', 'liveness']
|
|
21
|
+
|
|
22
|
+
export type Direction = 'agree' | 'pty-behind' | 'pty-ahead'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Direction of a (pty, tmux) verdict pair. tmux is AUTHORITATIVE; `true` is the
|
|
26
|
+
* active/ready/alive pole. pty UNDER-reporting it (pty=false, tmux=true) is BEHIND — the
|
|
27
|
+
* stale-frame class (zero tolerance). pty OVER-reporting (pty=true, tmux=false) is AHEAD.
|
|
28
|
+
*/
|
|
29
|
+
export function classifyDirection(ptyVal: boolean, tmuxVal: boolean): Direction {
|
|
30
|
+
if (ptyVal === tmuxVal) return 'agree'
|
|
31
|
+
return tmuxVal ? 'pty-behind' : 'pty-ahead'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type SampleState = 'idle' | 'model-advancing' | 'composer-busy'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Per-sample fleet state for the coverage matrix. composer-busy when tmux (authoritative) sees
|
|
38
|
+
* human occupancy; else model-advancing when output churned this tick (the model is actively
|
|
39
|
+
* rendering — the NON-trivial fidelity regime, not at rest); else idle. The split is what keeps
|
|
40
|
+
* an idle burn-in from reading as a false green (boris's acceptance lesson).
|
|
41
|
+
*/
|
|
42
|
+
export function classifySampleState(occTmux: boolean, bytesFedThisTick: number): SampleState {
|
|
43
|
+
if (occTmux) return 'composer-busy'
|
|
44
|
+
if (bytesFedThisTick > 0) return 'model-advancing'
|
|
45
|
+
return 'idle'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface VerdictSample {
|
|
49
|
+
/** null when the tmux side was unavailable this tick (capture failed) — skipped, not scored. */
|
|
50
|
+
pty: boolean
|
|
51
|
+
tmux: boolean | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PeerSample {
|
|
55
|
+
peer: string
|
|
56
|
+
runtime: string
|
|
57
|
+
occupancy: VerdictSample
|
|
58
|
+
readyGate: VerdictSample
|
|
59
|
+
liveness: VerdictSample
|
|
60
|
+
/** Bytes appended to this peer's pane-log since the previous tick (drives state coverage). */
|
|
61
|
+
bytesFed: number
|
|
62
|
+
/** The model already carries scrollback (transcript taller than the pane) — a history-bearing
|
|
63
|
+
* peer; at first sight this means it woke WITH resume-history (vs a fresh empty birth). */
|
|
64
|
+
hasHistory: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface GateConfig {
|
|
68
|
+
/** Consecutive tmux-ready ticks after which a peer is considered booted (out of the boot
|
|
69
|
+
* window). Default 3. */
|
|
70
|
+
settleTicks?: number
|
|
71
|
+
/** Max ticks an ahead-transient may stay open and still count as a benign self-heal.
|
|
72
|
+
* Default 3. */
|
|
73
|
+
convergeBudgetTicks?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface HardViolation {
|
|
77
|
+
peer: string
|
|
78
|
+
runtime: string
|
|
79
|
+
verdict: Verdict
|
|
80
|
+
tick: number
|
|
81
|
+
kind: 'behind' | 'ahead-steady' | 'ahead-nonconverged'
|
|
82
|
+
detail?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface OpenAhead {
|
|
86
|
+
startTick: number
|
|
87
|
+
bootWindow: boolean
|
|
88
|
+
flaggedNonConverged: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface PeerState {
|
|
92
|
+
runtime: string
|
|
93
|
+
firstTick: number
|
|
94
|
+
readyStreak: number
|
|
95
|
+
bootDone: boolean
|
|
96
|
+
open: Record<Verdict, OpenAhead | null>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface VerdictTally {
|
|
100
|
+
samples: number
|
|
101
|
+
agree: number
|
|
102
|
+
behind: number
|
|
103
|
+
aheadBenign: number
|
|
104
|
+
aheadHard: number
|
|
105
|
+
aheadLatencies: number[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class GateTracker {
|
|
109
|
+
private readonly settleTicks: number
|
|
110
|
+
private readonly budget: number
|
|
111
|
+
private peers = new Map<string, PeerState>()
|
|
112
|
+
private goneOnce = new Set<string>()
|
|
113
|
+
private tally: Record<Verdict, VerdictTally>
|
|
114
|
+
private hard: HardViolation[] = []
|
|
115
|
+
// coverage
|
|
116
|
+
private fleetWidthMax = 0
|
|
117
|
+
private stateCells = new Map<string, number>() // `${runtime}/${state}` → count
|
|
118
|
+
private births = new Map<string, number>() // `${runtime}/${fresh|wake-resume}` → count
|
|
119
|
+
private restarts = new Map<string, number>() // runtime → count
|
|
120
|
+
private historySamples = new Map<string, number>() // runtime → count
|
|
121
|
+
private runtimesSeen = new Set<string>()
|
|
122
|
+
|
|
123
|
+
constructor(cfg: GateConfig = {}) {
|
|
124
|
+
this.settleTicks = cfg.settleTicks ?? 3
|
|
125
|
+
this.budget = cfg.convergeBudgetTicks ?? 3
|
|
126
|
+
const mk = (): VerdictTally => ({ samples: 0, agree: 0, behind: 0, aheadBenign: 0, aheadHard: 0, aheadLatencies: [] })
|
|
127
|
+
this.tally = { occupancy: mk(), readyGate: mk(), liveness: mk() }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Once per tick, before the per-peer samples — records the concurrent fleet width. */
|
|
131
|
+
tickStart(nPeers: number): void {
|
|
132
|
+
if (nPeers > this.fleetWidthMax) this.fleetWidthMax = nPeers
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The observer dropped a peer (session gone) — a later reappearance is a restart. */
|
|
136
|
+
peerGone(addr: string): void {
|
|
137
|
+
if (this.peers.delete(addr)) this.goneOnce.add(addr)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private inc(map: Map<string, number>, key: string): void {
|
|
141
|
+
map.set(key, (map.get(key) ?? 0) + 1)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Record one peer's sample at `tick`. Updates boot state, classifies every verdict's
|
|
145
|
+
* direction, manages ahead-transients, and accumulates the coverage matrix. */
|
|
146
|
+
sample(tick: number, s: PeerSample): void {
|
|
147
|
+
this.runtimesSeen.add(s.runtime)
|
|
148
|
+
let ps = this.peers.get(s.peer)
|
|
149
|
+
if (!ps) {
|
|
150
|
+
// First sight this lifetime → a birth (or a restart if we saw it die before).
|
|
151
|
+
const birthType = s.hasHistory ? 'wake-resume' : 'fresh'
|
|
152
|
+
this.inc(this.births, `${s.runtime}/${birthType}`)
|
|
153
|
+
if (this.goneOnce.has(s.peer)) {
|
|
154
|
+
this.inc(this.restarts, s.runtime)
|
|
155
|
+
this.goneOnce.delete(s.peer)
|
|
156
|
+
}
|
|
157
|
+
ps = { runtime: s.runtime, firstTick: tick, readyStreak: 0, bootDone: false, open: { occupancy: null, readyGate: null, liveness: null } }
|
|
158
|
+
this.peers.set(s.peer, ps)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Boot-window: a peer is booted once tmux-ready has held true for settleTicks in a row.
|
|
162
|
+
if (s.readyGate.tmux === true) {
|
|
163
|
+
ps.readyStreak++
|
|
164
|
+
if (ps.readyStreak >= this.settleTicks) ps.bootDone = true
|
|
165
|
+
} else if (s.readyGate.tmux === false) {
|
|
166
|
+
ps.readyStreak = 0
|
|
167
|
+
}
|
|
168
|
+
const bootWindow = !ps.bootDone
|
|
169
|
+
|
|
170
|
+
// Coverage.
|
|
171
|
+
const state = classifySampleState(s.occupancy.tmux === true, s.bytesFed)
|
|
172
|
+
this.inc(this.stateCells, `${s.runtime}/${state}`)
|
|
173
|
+
if (s.hasHistory) this.inc(this.historySamples, s.runtime)
|
|
174
|
+
|
|
175
|
+
// Per-verdict direction + transient bookkeeping.
|
|
176
|
+
this.scoreVerdict('occupancy', tick, ps, bootWindow, s.runtime, s.peer, s.occupancy)
|
|
177
|
+
this.scoreVerdict('readyGate', tick, ps, bootWindow, s.runtime, s.peer, s.readyGate)
|
|
178
|
+
this.scoreVerdict('liveness', tick, ps, bootWindow, s.runtime, s.peer, s.liveness)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private scoreVerdict(
|
|
182
|
+
verdict: Verdict,
|
|
183
|
+
tick: number,
|
|
184
|
+
ps: PeerState,
|
|
185
|
+
bootWindow: boolean,
|
|
186
|
+
runtime: string,
|
|
187
|
+
peer: string,
|
|
188
|
+
vs: VerdictSample,
|
|
189
|
+
): void {
|
|
190
|
+
if (vs.tmux === null) return // tmux side unavailable → not scored
|
|
191
|
+
const t = this.tally[verdict]
|
|
192
|
+
t.samples++
|
|
193
|
+
const dir = classifyDirection(vs.pty, vs.tmux)
|
|
194
|
+
|
|
195
|
+
// An open ahead-transient that exceeds the converge budget is a hard violation the moment
|
|
196
|
+
// it overruns (logged once), regardless of how it later resolves.
|
|
197
|
+
const open = ps.open[verdict]
|
|
198
|
+
if (open && !open.flaggedNonConverged && tick - open.startTick > this.budget) {
|
|
199
|
+
open.flaggedNonConverged = true
|
|
200
|
+
t.aheadHard++
|
|
201
|
+
this.hard.push({ peer, runtime, verdict, tick, kind: 'ahead-nonconverged', detail: `ahead open ${tick - open.startTick} ticks > budget ${this.budget}` })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (dir === 'agree') {
|
|
205
|
+
t.agree++
|
|
206
|
+
if (open) {
|
|
207
|
+
// transient closed → measure self-heal latency; benign iff it opened in the boot
|
|
208
|
+
// window AND healed within budget (and was not already flagged non-converged).
|
|
209
|
+
const latency = tick - open.startTick
|
|
210
|
+
t.aheadLatencies.push(latency)
|
|
211
|
+
if (!open.flaggedNonConverged) {
|
|
212
|
+
if (open.bootWindow && latency <= this.budget) t.aheadBenign++
|
|
213
|
+
else {
|
|
214
|
+
t.aheadHard++
|
|
215
|
+
this.hard.push({ peer, runtime, verdict, tick, kind: open.bootWindow ? 'ahead-nonconverged' : 'ahead-steady', detail: `latency ${latency}, boot=${open.bootWindow}` })
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
ps.open[verdict] = null
|
|
219
|
+
}
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (dir === 'pty-behind') {
|
|
224
|
+
// The stale-frame class — always hard, zero tolerance. A behind also closes any open
|
|
225
|
+
// ahead-transient as an abnormal resolution (not a benign heal).
|
|
226
|
+
t.behind++
|
|
227
|
+
this.hard.push({ peer, runtime, verdict, tick, kind: 'behind' })
|
|
228
|
+
ps.open[verdict] = null
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// pty-ahead: open a transient if none is open. Steady-state ahead is hard immediately
|
|
233
|
+
// (it cannot be a boot self-heal); boot-window ahead waits to see if it converges.
|
|
234
|
+
if (!open) {
|
|
235
|
+
if (!bootWindow) {
|
|
236
|
+
t.aheadHard++
|
|
237
|
+
this.hard.push({ peer, runtime, verdict, tick, kind: 'ahead-steady' })
|
|
238
|
+
// still open it so we can measure when it clears, but it is already counted hard
|
|
239
|
+
ps.open[verdict] = { startTick: tick, bootWindow: false, flaggedNonConverged: true }
|
|
240
|
+
} else {
|
|
241
|
+
ps.open[verdict] = { startTick: tick, bootWindow: true, flaggedNonConverged: false }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** True iff no hard divergence has been recorded for `verdict` (the per-verdict gate bar). */
|
|
247
|
+
verdictGateClean(verdict: Verdict): boolean {
|
|
248
|
+
const t = this.tally[verdict]
|
|
249
|
+
return t.behind === 0 && t.aheadHard === 0
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
summary(): GateSummary {
|
|
253
|
+
const latStats = (xs: number[]): { count: number; max: number; mean: number } =>
|
|
254
|
+
xs.length ? { count: xs.length, max: Math.max(...xs), mean: +(xs.reduce((a, b) => a + b, 0) / xs.length).toFixed(2) } : { count: 0, max: 0, mean: 0 }
|
|
255
|
+
const verdicts = {} as Record<Verdict, VerdictGateStat>
|
|
256
|
+
for (const v of VERDICTS) {
|
|
257
|
+
const t = this.tally[v]
|
|
258
|
+
verdicts[v] = {
|
|
259
|
+
samples: t.samples,
|
|
260
|
+
agree: t.agree,
|
|
261
|
+
behind: t.behind,
|
|
262
|
+
aheadBenign: t.aheadBenign,
|
|
263
|
+
aheadHard: t.aheadHard,
|
|
264
|
+
selfHeal: latStats(t.aheadLatencies),
|
|
265
|
+
gateClean: this.verdictGateClean(v),
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
verdicts,
|
|
270
|
+
hardViolations: this.hard.slice(),
|
|
271
|
+
coverage: {
|
|
272
|
+
fleetWidthMax: this.fleetWidthMax,
|
|
273
|
+
runtimes: [...this.runtimesSeen].sort(),
|
|
274
|
+
byRuntimeState: Object.fromEntries([...this.stateCells].sort()),
|
|
275
|
+
births: Object.fromEntries([...this.births].sort()),
|
|
276
|
+
restarts: Object.fromEntries([...this.restarts].sort()),
|
|
277
|
+
historySamples: Object.fromEntries([...this.historySamples].sort()),
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface VerdictGateStat {
|
|
284
|
+
samples: number
|
|
285
|
+
agree: number
|
|
286
|
+
behind: number
|
|
287
|
+
aheadBenign: number
|
|
288
|
+
aheadHard: number
|
|
289
|
+
selfHeal: { count: number; max: number; mean: number }
|
|
290
|
+
gateClean: boolean
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface GateCoverage {
|
|
294
|
+
fleetWidthMax: number
|
|
295
|
+
runtimes: string[]
|
|
296
|
+
byRuntimeState: Record<string, number>
|
|
297
|
+
births: Record<string, number>
|
|
298
|
+
restarts: Record<string, number>
|
|
299
|
+
historySamples: Record<string, number>
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface GateSummary {
|
|
303
|
+
verdicts: Record<Verdict, VerdictGateStat>
|
|
304
|
+
hardViolations: HardViolation[]
|
|
305
|
+
coverage: GateCoverage
|
|
306
|
+
}
|
package/src/shadow/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { tmux, composerCaptureHasHumanInput, listOnlinePeers } from '../transpor
|
|
|
19
19
|
import { getAdapter } from '../launch/index.ts'
|
|
20
20
|
import { buildSocketPath, buildProcessAddress } from '../core/socket.ts'
|
|
21
21
|
import type { Runtime } from '../core/constants.ts'
|
|
22
|
+
import { GateTracker, classifyDirection, VERDICTS } from './gate.ts'
|
|
22
23
|
|
|
23
24
|
const SEED_BYTES = 4 * 1024 * 1024
|
|
24
25
|
const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
|
|
@@ -72,16 +73,18 @@ export interface ShadowOptions {
|
|
|
72
73
|
log?: (s: string) => void
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
/** Feed the pane-log delta into the model; resolves with the byte count appended this tick
|
|
77
|
+
* (drives the coverage state classifier: >0 ⇒ the model is advancing, not idle). */
|
|
78
|
+
function feed(o: Obs): Promise<number> {
|
|
76
79
|
const sz = fstatSync(o.fd).size
|
|
77
|
-
if (sz <= o.offset) return Promise.resolve()
|
|
80
|
+
if (sz <= o.offset) return Promise.resolve(0)
|
|
78
81
|
const buf = Buffer.alloc(sz - o.offset)
|
|
79
82
|
readSync(o.fd, buf, 0, buf.length, o.offset)
|
|
80
83
|
o.offset = sz
|
|
81
|
-
return new Promise(res => o.term.write(buf, () => res()))
|
|
84
|
+
return new Promise(res => o.term.write(buf, () => res(buf.length)))
|
|
82
85
|
}
|
|
83
86
|
|
|
84
|
-
function enumerate(models: Map<string, Obs>, opts: ShadowOptions): void {
|
|
87
|
+
function enumerate(models: Map<string, Obs>, opts: ShadowOptions, tracker?: GateTracker): void {
|
|
85
88
|
let peers: { runtime: Runtime; personality: string }[] = []
|
|
86
89
|
try { peers = listOnlinePeers(opts.sockDir).filter(p => p.runtime === 'claude' || p.runtime === 'codex') } catch { return }
|
|
87
90
|
const live = new Set<string>()
|
|
@@ -98,7 +101,7 @@ function enumerate(models: Map<string, Obs>, opts: ShadowOptions): void {
|
|
|
98
101
|
models.set(addr, { addr, sock, runtime: p.runtime, cols, rows, term, fd, offset })
|
|
99
102
|
} catch { /* a peer racing spawn/teardown — skip this round */ }
|
|
100
103
|
}
|
|
101
|
-
for (const [addr, o] of models) if (!live.has(addr)) { try { closeSync(o.fd) } catch { /* */ }; models.delete(addr) }
|
|
104
|
+
for (const [addr, o] of models) if (!live.has(addr)) { try { closeSync(o.fd) } catch { /* */ }; tracker?.peerGone(addr); models.delete(addr) }
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
/** Run the read-only fidelity observer. Resolves when once/maxMinutes/STOP completes. */
|
|
@@ -110,19 +113,20 @@ export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
|
|
|
110
113
|
const interval = opts.intervalMs ?? 5000
|
|
111
114
|
const log = opts.log ?? (() => { /* */ })
|
|
112
115
|
const models = new Map<string, Obs>()
|
|
113
|
-
const
|
|
116
|
+
const tracker = new GateTracker()
|
|
114
117
|
const startedISO = new Date().toISOString(), t0 = Date.now()
|
|
115
|
-
enumerate(models, opts)
|
|
118
|
+
enumerate(models, opts, tracker)
|
|
116
119
|
for (const o of models.values()) await feed(o) // seed each model
|
|
117
120
|
log(`shadow-fidelity: ${models.size} warm peers, interval ${interval}ms\n`)
|
|
118
121
|
let i = 0
|
|
119
122
|
for (;;) {
|
|
120
123
|
if (opts.maxMinutes && Date.now() - t0 >= opts.maxMinutes * 60000) break
|
|
121
124
|
if (existsSync(stopFile)) break
|
|
122
|
-
if (i > 0 && i % 15 === 0) enumerate(models, opts)
|
|
125
|
+
if (i > 0 && i % 15 === 0) enumerate(models, opts, tracker)
|
|
126
|
+
tracker.tickStart(models.size)
|
|
123
127
|
for (const o of models.values()) {
|
|
124
128
|
try {
|
|
125
|
-
await feed(o)
|
|
129
|
+
const bytesFed = await feed(o)
|
|
126
130
|
const adapter = getAdapter(o.runtime)
|
|
127
131
|
const capE = tmux(o.sock, 'capture-pane', '-e', '-p', '-t', o.addr); if (!capE.ok) continue
|
|
128
132
|
const occTmux = composerCaptureHasHumanInput(capE.out, adapter.deliveryMarkers)
|
|
@@ -134,34 +138,49 @@ export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
|
|
|
134
138
|
const ppid = tmux(o.sock, 'display', '-p', '-t', o.addr, '#{pane_pid}')
|
|
135
139
|
const pid = ppid.ok ? Number(ppid.out.trim()) : NaN
|
|
136
140
|
const livPty = Number.isFinite(pid) ? pidAlive(pid) : false
|
|
141
|
+
const hasHistory = o.term.buffer.active.baseY > 0
|
|
137
142
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
// Feed the gate tracker — it scores DIRECTION (behind=hard / ahead=benign-if-boot-self-heal)
|
|
144
|
+
// and accumulates the COVERAGE matrix. The boolean booleans never leave this call.
|
|
145
|
+
tracker.sample(i, {
|
|
146
|
+
peer: o.addr, runtime: o.runtime,
|
|
147
|
+
occupancy: { pty: occPty, tmux: occTmux },
|
|
148
|
+
readyGate: { pty: rgPty, tmux: rgTmux },
|
|
149
|
+
liveness: { pty: livPty, tmux: livTmux },
|
|
150
|
+
bytesFed, hasHistory,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// DIRECTION-AWARE per-event divergence trail (gate scoring lives in the tracker; this is
|
|
154
|
+
// the forensic log). Occupancy carries the raw capture + model frames for root-cause.
|
|
155
|
+
const occDir = classifyDirection(occPty, occTmux)
|
|
156
|
+
const rgDir = rgTmux === null ? 'agree' : classifyDirection(rgPty, rgTmux)
|
|
157
|
+
const livDir = classifyDirection(livPty, livTmux)
|
|
158
|
+
if (occDir !== 'agree' || rgDir !== 'agree' || livDir !== 'agree') {
|
|
146
159
|
appendFileSync(jsonl, JSON.stringify({
|
|
147
|
-
ts: new Date().toISOString(), peer: o.addr, geom: `${o.cols}x${o.rows}`,
|
|
148
|
-
occupancy: { pty: occPty, tmux: occTmux,
|
|
149
|
-
readyGate: { pty: rgPty, tmux: rgTmux,
|
|
150
|
-
liveness: { pty: livPty, tmux: livTmux,
|
|
151
|
-
...(
|
|
160
|
+
ts: new Date().toISOString(), peer: o.addr, geom: `${o.cols}x${o.rows}`, hasHistory,
|
|
161
|
+
occupancy: { pty: occPty, tmux: occTmux, dir: occDir },
|
|
162
|
+
readyGate: { pty: rgPty, tmux: rgTmux, dir: rgDir },
|
|
163
|
+
liveness: { pty: livPty, tmux: livTmux, dir: livDir },
|
|
164
|
+
...(occDir !== 'agree' ? { capE: capE.out.slice(-2500), model: modelToPlainText(o.term, o.cols, o.rows).slice(-2500) } : {}),
|
|
152
165
|
}) + '\n')
|
|
153
|
-
log(`DIVERGE ${o.addr} occ=${
|
|
166
|
+
log(`DIVERGE ${o.addr} occ=${occDir} rg=${rgDir} liv=${livDir}\n`)
|
|
154
167
|
}
|
|
155
168
|
} catch { /* an observer error must NEVER escape — it is a passive sidecar */ }
|
|
156
169
|
}
|
|
157
170
|
i++
|
|
171
|
+
const gs = tracker.summary()
|
|
172
|
+
const totalSamples = VERDICTS.reduce((n, v) => n + gs.verdicts[v].samples, 0)
|
|
158
173
|
writeFileSync(statsFile, JSON.stringify({
|
|
159
174
|
startedISO, lastISO: new Date().toISOString(), elapsedMin: +((Date.now() - t0) / 60000).toFixed(1),
|
|
160
|
-
peers: models.size, samples:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
175
|
+
peers: models.size, samples: totalSamples,
|
|
176
|
+
// Gate (boris's criterion): clean iff zero hard divergences across all verdicts. Hard =
|
|
177
|
+
// every pty-behind + non-benign pty-ahead (steady-state or non-converging). Benign =
|
|
178
|
+
// boot-window ahead that self-heals within budget. Coverage proves the matrix was traversed.
|
|
179
|
+
gateClean: VERDICTS.every(v => gs.verdicts[v].gateClean),
|
|
180
|
+
hardTotal: gs.hardViolations.length,
|
|
181
|
+
verdicts: gs.verdicts,
|
|
182
|
+
coverage: gs.coverage,
|
|
183
|
+
recentHard: gs.hardViolations.slice(-20),
|
|
165
184
|
}, null, 2))
|
|
166
185
|
if (opts.once) break
|
|
167
186
|
await new Promise(r => setTimeout(r, interval))
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { isFoundationOwnedPlist } from '../launch/launchd.ts'
|
|
7
|
+
import {
|
|
8
|
+
SHADOW_PERSONALITY,
|
|
9
|
+
SHADOW_PLIST_LABEL,
|
|
10
|
+
buildShadowPlistSpec,
|
|
11
|
+
cycleShadowJob,
|
|
12
|
+
installShadowJob,
|
|
13
|
+
installShadowPlist,
|
|
14
|
+
shadowPlistPath,
|
|
15
|
+
uninstallShadowJob,
|
|
16
|
+
} from './install.ts'
|
|
17
|
+
|
|
18
|
+
// Hermetic: every plist write lands under IAPEER_LAUNCHAGENTS_DIR / IAPEER_ROOT overrides, and
|
|
19
|
+
// the suite runs with IAPEER_TEST_SANDBOX=1 (process.env) so the launchctl-touching paths
|
|
20
|
+
// (installShadowJob / cycleShadowJob / uninstallShadowJob) fail-CLOSED to skipped-sandbox — they
|
|
21
|
+
// NEVER load/unload a real launchd job. No tmux, no @xterm (install.ts is pure plumbing).
|
|
22
|
+
|
|
23
|
+
const tmpDirs: string[] = []
|
|
24
|
+
function mkEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
|
|
25
|
+
const root = mkdtempSync(join(tmpdir(), 'iapeer-shadow-install-'))
|
|
26
|
+
tmpDirs.push(root)
|
|
27
|
+
return {
|
|
28
|
+
IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
|
|
29
|
+
IAPEER_ROOT: join(root, 'iapeer'),
|
|
30
|
+
HOME: root,
|
|
31
|
+
PATH: '/usr/bin:/bin',
|
|
32
|
+
...extra,
|
|
33
|
+
} as NodeJS.ProcessEnv
|
|
34
|
+
}
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true })
|
|
37
|
+
})
|
|
38
|
+
const plutilLint = (path: string): boolean => spawnSync('plutil', ['-lint', path], { encoding: 'utf8' }).status === 0
|
|
39
|
+
|
|
40
|
+
describe('buildShadowPlistSpec', () => {
|
|
41
|
+
test('label is com.iapeer.shadow-fidelity and runs the INSTALLED `iapeer shadow`', () => {
|
|
42
|
+
const spec = buildShadowPlistSpec({ HOME: '/Users/x' } as NodeJS.ProcessEnv)
|
|
43
|
+
expect(spec.label).toBe(SHADOW_PLIST_LABEL)
|
|
44
|
+
expect(SHADOW_PLIST_LABEL).toBe('com.iapeer.shadow-fidelity')
|
|
45
|
+
expect(spec.programArguments).toEqual(['/Users/x/.local/bin/iapeer', 'shadow'])
|
|
46
|
+
})
|
|
47
|
+
test('launchd-minimal PATH includes /opt/homebrew/bin (tmux lives there — the observer shells read-only tmux)', () => {
|
|
48
|
+
expect(buildShadowPlistSpec({ HOME: '/Users/x' } as NodeJS.ProcessEnv).environment.PATH).toContain('/opt/homebrew/bin')
|
|
49
|
+
})
|
|
50
|
+
test('stdout/stderr land under ~/.iapeer/logs/iapeer', () => {
|
|
51
|
+
const spec = buildShadowPlistSpec(mkEnv())
|
|
52
|
+
expect(spec.stdoutPath).toContain(join('logs', 'iapeer', 'shadow-fidelity.launchd-stdout.log'))
|
|
53
|
+
expect(spec.stderrPath).toContain(join('logs', 'iapeer', 'shadow-fidelity.launchd-stderr.log'))
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('installShadowPlist', () => {
|
|
58
|
+
test('writes a valid, foundation-owned (sentinel) always-on plist under the override dir', () => {
|
|
59
|
+
const env = mkEnv()
|
|
60
|
+
const r = installShadowPlist(env)
|
|
61
|
+
expect(r.path).toBe(shadowPlistPath(env))
|
|
62
|
+
expect(r.changed).toBe(true)
|
|
63
|
+
expect(existsSync(r.path)).toBe(true)
|
|
64
|
+
expect(isFoundationOwnedPlist(r.path)).toBe(true) // carries the ownership sentinel
|
|
65
|
+
expect(plutilLint(r.path)).toBe(true)
|
|
66
|
+
const xml = readFileSync(r.path, 'utf8')
|
|
67
|
+
expect(xml).toContain(`<string>${SHADOW_PLIST_LABEL}</string>`)
|
|
68
|
+
expect(xml).toContain('<string>shadow</string>')
|
|
69
|
+
expect(xml).toContain('<key>RunAtLoad</key>')
|
|
70
|
+
expect(xml).toContain('<key>KeepAlive</key>')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('idempotent by content: an unchanged re-install does NOT rewrite (changed:false, mtime stable); a changed PATH DOES', () => {
|
|
74
|
+
const env = mkEnv()
|
|
75
|
+
const first = installShadowPlist(env)
|
|
76
|
+
expect(first.changed).toBe(true)
|
|
77
|
+
const mtime1 = statSync(first.path).mtimeMs
|
|
78
|
+
const second = installShadowPlist(env)
|
|
79
|
+
expect(second.changed).toBe(false)
|
|
80
|
+
expect(statSync(second.path).mtimeMs).toBe(mtime1) // file untouched (no BTM spam)
|
|
81
|
+
// a different rendered plist (PATH override) rewrites
|
|
82
|
+
const third = installShadowPlist(mkEnv({ IAPEER_SHADOW_PATH: '/opt/homebrew/bin:/usr/bin:/bin', IAPEER_LAUNCHAGENTS_DIR: env.IAPEER_LAUNCHAGENTS_DIR!, IAPEER_ROOT: env.IAPEER_ROOT!, HOME: env.HOME! }))
|
|
83
|
+
expect(third.changed).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('collision guard: REFUSES to overwrite a foreign (no-sentinel) plist at the label', () => {
|
|
87
|
+
const env = mkEnv()
|
|
88
|
+
const laDir = env.IAPEER_LAUNCHAGENTS_DIR!
|
|
89
|
+
mkdirSync(laDir, { recursive: true })
|
|
90
|
+
const path = shadowPlistPath(env)
|
|
91
|
+
const foreign = '<?xml version="1.0"?>\n<plist><dict><key>Label</key><string>com.iapeer.shadow-fidelity</string></dict></plist>\n'
|
|
92
|
+
writeFileSync(path, foreign)
|
|
93
|
+
expect(() => installShadowPlist(env)).toThrow(/foundation-managed|refus/i)
|
|
94
|
+
expect(readFileSync(path, 'utf8')).toBe(foreign) // untouched — guard fires before any write
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('installShadowJob (sandbox fail-closed)', () => {
|
|
99
|
+
test('writes the plist FILE but NEVER touches launchctl under IAPEER_TEST_SANDBOX → skipped-sandbox', () => {
|
|
100
|
+
const env = mkEnv()
|
|
101
|
+
const r = installShadowJob(env)
|
|
102
|
+
expect(r.action).toBe('skipped-sandbox')
|
|
103
|
+
expect(r.path).toBe(shadowPlistPath(env))
|
|
104
|
+
expect(r.changed).toBe(true)
|
|
105
|
+
expect(existsSync(r.path)).toBe(true) // file written to the override dir
|
|
106
|
+
expect(isFoundationOwnedPlist(r.path)).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('cycleShadowJob (deploy re-cycle hook)', () => {
|
|
111
|
+
test('returns null when no plist exists (nothing to recycle)', () => {
|
|
112
|
+
expect(cycleShadowJob(mkEnv())).toBeNull()
|
|
113
|
+
})
|
|
114
|
+
test('a written foundation plist recycles to skipped-sandbox under the test flag (never a real launchctl cycle)', () => {
|
|
115
|
+
const env = mkEnv()
|
|
116
|
+
installShadowPlist(env)
|
|
117
|
+
expect(cycleShadowJob(env)?.state).toBe('skipped-sandbox')
|
|
118
|
+
})
|
|
119
|
+
test('a foreign plist at the label is NOT recycled (ownership guard) → null', () => {
|
|
120
|
+
const env = mkEnv()
|
|
121
|
+
mkdirSync(env.IAPEER_LAUNCHAGENTS_DIR!, { recursive: true })
|
|
122
|
+
writeFileSync(shadowPlistPath(env), '<?xml version="1.0"?>\n<plist><dict/></plist>\n')
|
|
123
|
+
expect(cycleShadowJob(env)).toBeNull()
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('uninstallShadowJob', () => {
|
|
128
|
+
test('absent plist → absent (no-op)', () => {
|
|
129
|
+
expect(uninstallShadowJob(mkEnv()).action).toBe('absent')
|
|
130
|
+
})
|
|
131
|
+
test('foundation plist under sandbox → skipped-sandbox, plist left in place (no real bootout)', () => {
|
|
132
|
+
const env = mkEnv()
|
|
133
|
+
installShadowPlist(env)
|
|
134
|
+
const r = uninstallShadowJob(env)
|
|
135
|
+
expect(r.action).toBe('skipped-sandbox')
|
|
136
|
+
expect(existsSync(shadowPlistPath(env))).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
test('foreign plist → refused-foreign, untouched', () => {
|
|
139
|
+
const env = mkEnv()
|
|
140
|
+
mkdirSync(env.IAPEER_LAUNCHAGENTS_DIR!, { recursive: true })
|
|
141
|
+
const path = shadowPlistPath(env)
|
|
142
|
+
writeFileSync(path, '<?xml version="1.0"?>\n<plist><dict/></plist>\n')
|
|
143
|
+
expect(uninstallShadowJob(env).action).toBe('refused-foreign')
|
|
144
|
+
expect(existsSync(path)).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('exports wire to the running job', () => {
|
|
149
|
+
test('SHADOW_PERSONALITY/LABEL match the live burn-in job naming', () => {
|
|
150
|
+
expect(SHADOW_PERSONALITY).toBe('shadow-fidelity')
|
|
151
|
+
expect(SHADOW_PLIST_LABEL).toBe('com.iapeer.shadow-fidelity')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,256 @@
|
|
|
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
|
+
}
|
package/src/update/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
type DaemonRestartResult,
|
|
42
42
|
type LaunchdJobCycleResult,
|
|
43
43
|
} from '../launch/launchd.ts'
|
|
44
|
+
import { cycleShadowJob, SHADOW_PERSONALITY, SHADOW_PLIST_LABEL } from '../shadow/install.ts'
|
|
44
45
|
import { defaultDaemonSocketPath } from '../daemon/index.ts'
|
|
45
46
|
|
|
46
47
|
/** The npm package the foundation publishes / updates from. */
|
|
@@ -242,6 +243,14 @@ export function recycleFoundationOwnedInfraJobs(env: NodeJS.ProcessEnv = process
|
|
|
242
243
|
const r = cycleLaunchdJob(label, plist, env)
|
|
243
244
|
out.push({ personality: peer.personality, runtime, label, state: r.state, detail: r.detail })
|
|
244
245
|
}
|
|
246
|
+
// The tmux→pty fidelity BURN-IN job (com.iapeer.shadow-fidelity) is foundation-owned and
|
|
247
|
+
// runs the just-replaced `iapeer shadow` off the same binary, so it carries the same LWCR
|
|
248
|
+
// constraint — but it is NOT a registry peer, so the scan above cannot reach it. Cycle it
|
|
249
|
+
// by label too (no-op when absent / not-loaded / foreign).
|
|
250
|
+
const shadow = cycleShadowJob(env)
|
|
251
|
+
if (shadow) {
|
|
252
|
+
out.push({ personality: SHADOW_PERSONALITY, runtime: 'foundation', label: SHADOW_PLIST_LABEL, state: shadow.state, detail: shadow.detail })
|
|
253
|
+
}
|
|
245
254
|
return out
|
|
246
255
|
}
|
|
247
256
|
|