@agfpd/iapeer 0.4.109 → 0.4.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/docs/04-lifecycle-and-daemon.md +11 -0
  3. package/docs/06-storage.md +2 -1
  4. package/docs/07-install-and-update.md +3 -3
  5. package/docs/10-messaging.md +12 -0
  6. package/docs/12-cli-reference.md +1 -1
  7. package/docs/13-architecture-and-contract.md +44 -0
  8. package/docs/15-fleet-api.md +2 -2
  9. package/docs/18-origin-guard.md +2 -2
  10. package/docs/ru/04-/320/266/320/270/320/267/320/275/320/265/320/275/320/275/321/213/320/271-/321/206/320/270/320/272/320/273-/320/270-/320/264/320/265/320/274/320/276/320/275.md +11 -0
  11. package/docs/ru/06-/321/205/321/200/320/260/320/275/320/265/320/275/320/270/320/265.md +2 -1
  12. package/docs/ru/07-/321/203/321/201/321/202/320/260/320/275/320/276/320/262/320/272/320/260-/320/270-/320/276/320/261/320/275/320/276/320/262/320/273/320/265/320/275/320/270/320/265.md +3 -3
  13. package/docs/ru/10-/320/276/320/261/320/274/320/265/320/275-/321/201/320/276/320/276/320/261/321/211/320/265/320/275/320/270/321/217/320/274/320/270.md +12 -0
  14. package/docs/ru/12-cli-reference.md +1 -1
  15. package/docs/ru/13-/320/260/321/200/321/205/320/270/321/202/320/265/320/272/321/202/321/203/321/200/320/260-/320/270-/320/272/320/276/320/275/321/202/321/200/320/260/320/272/321/202.md +46 -0
  16. package/docs/ru/15-fleet-api.md +2 -2
  17. package/docs/ru/18-origin-guard.md +2 -2
  18. package/package.json +4 -2
  19. package/scripts/proof-install-signature.ts +189 -0
  20. package/src/cli/index.ts +25 -3
  21. package/src/daemon/fleet.ts +8 -1
  22. package/src/daemon/index.ts +1 -1
  23. package/src/install/index.ts +41 -16
  24. package/src/install/signature.ts +94 -0
  25. package/src/install/signing.ts +7 -7
  26. package/src/launch/adapters/claude.ts +7 -0
  27. package/src/launch/launchdRun.ts +1 -1
  28. package/src/launch/ptyHost.ts +1 -1
  29. package/src/lifecycle/index.ts +1 -1
  30. package/src/runtime/deploy.ts +21 -0
  31. package/src/runtime/signature.ts +35 -0
  32. package/src/runtime/update.ts +11 -4
  33. package/src/supervisor/daemon.ts +12 -1
  34. package/src/supervisor/fixtures/owned-stdio.ts +57 -0
  35. package/src/supervisor/processTree.ts +71 -0
  36. package/src/transport/attachments.ts +163 -0
  37. package/src/transport/index.ts +28 -4
  38. package/src/update/index.ts +21 -1
@@ -22,6 +22,7 @@ import { type Runtime } from '../core/constants.ts'
22
22
  import { IapError } from '../core/errors.ts'
23
23
  import { createPeer, type CreatePeerResult } from '../create/index.ts'
24
24
  import { readRuntimeManifest, writeRuntimeManifest, type RuntimeManifest, type RuntimePeerDecl } from './index.ts'
25
+ import { verifyRuntimeSignatures, type RuntimeSignatureVerifier } from './signature.ts'
25
26
 
26
27
  // ─────────────────────────────────────────────────────────────────────────────
27
28
  // Built-in runtime → npm package registry (§6): onboard AUTO-resolves
@@ -52,6 +53,7 @@ export interface DeployRuntimeOptions {
52
53
  bootstrap?: boolean
53
54
  env?: NodeJS.ProcessEnv
54
55
  warn?: (message: string) => void
56
+ verifySignatures?: RuntimeSignatureVerifier
55
57
  }
56
58
 
57
59
  export interface DeployedPeer {
@@ -90,6 +92,8 @@ export async function deployRuntime(opts: DeployRuntimeOptions): Promise<DeployR
90
92
  )
91
93
  }
92
94
  const declared = opts.peers ?? manifest.peers ?? []
95
+ const verifySignatures = opts.verifySignatures ?? verifyRuntimeSignatures
96
+ verifySignatures(opts.runtime, env, { ...manifest, peers: declared })
93
97
  const peers: DeployedPeer[] = []
94
98
  for (const decl of declared) {
95
99
  const result = await createPeer({
@@ -164,6 +168,8 @@ export interface InstallRuntimePackageOptions {
164
168
  env?: NodeJS.ProcessEnv
165
169
  /** Injected npx runner (tests / sandbox proof). */
166
170
  runNpx?: NpxRunner
171
+ /** Verify-only activation gate; injected in hermetic orchestration tests. */
172
+ verifySignatures?: RuntimeSignatureVerifier
167
173
  }
168
174
 
169
175
  /**
@@ -183,6 +189,11 @@ export function installRuntimePackage(opts: InstallRuntimePackageOptions): Insta
183
189
  const existing = readRuntimeManifest(opts.runtime, { env })
184
190
  const pkg = resolveRuntimePackage(opts.runtime, opts.package ?? existing?.package)
185
191
  if (existing && !opts.force) {
192
+ try {
193
+ (opts.verifySignatures ?? verifyRuntimeSignatures)(opts.runtime, env)
194
+ } catch (e) {
195
+ return { runtime: opts.runtime, package: pkg, state: 'failed', detail: e instanceof Error ? e.message : String(e) }
196
+ }
186
197
  return { runtime: opts.runtime, package: pkg, state: 'skipped' }
187
198
  }
188
199
  if (!pkg) {
@@ -194,6 +205,13 @@ export function installRuntimePackage(opts: InstallRuntimePackageOptions): Insta
194
205
  if (!r.ok) {
195
206
  return { runtime: opts.runtime, package: pkg, state: 'failed', detail: r.detail }
196
207
  }
208
+ // Verification is not version-gated and precedes manifest stamping/provision.
209
+ // Never repair an installed launcher here: the package owns staged repair.
210
+ try {
211
+ (opts.verifySignatures ?? verifyRuntimeSignatures)(opts.runtime, env)
212
+ } catch (e) {
213
+ return { runtime: opts.runtime, package: pkg, state: 'failed', detail: e instanceof Error ? e.message : String(e) }
214
+ }
197
215
  // В52 — stamp the delivering package into the fresh manifest (best-effort: a
198
216
  // missing manifest is the package's own contract breach, surfaced elsewhere).
199
217
  try {
@@ -212,6 +230,7 @@ export interface OnboardRuntimeOptions extends DeployRuntimeOptions {
212
230
  npx?: boolean
213
231
  /** Injected npx runner (tests / sandbox proof). */
214
232
  runNpx?: NpxRunner
233
+ verifySignatures?: RuntimeSignatureVerifier
215
234
  }
216
235
 
217
236
  export interface OnboardRuntimeResult {
@@ -235,6 +254,7 @@ export async function onboardRuntime(opts: OnboardRuntimeOptions): Promise<Onboa
235
254
  force: opts.npx,
236
255
  env,
237
256
  runNpx: opts.runNpx,
257
+ verifySignatures: opts.verifySignatures,
238
258
  })
239
259
  if (install.state === 'failed') {
240
260
  throw new IapError(`npx install of runtime "${opts.runtime}" package ${install.package} failed: ${install.detail ?? ''}`)
@@ -250,6 +270,7 @@ export async function onboardRuntime(opts: OnboardRuntimeOptions): Promise<Onboa
250
270
  bootstrap: opts.bootstrap,
251
271
  env,
252
272
  warn: opts.warn,
273
+ verifySignatures: opts.verifySignatures,
253
274
  })
254
275
  return { install, deploy }
255
276
  }
@@ -0,0 +1,35 @@
1
+ // Secondary, verify-only gate. The package must verify its staged inode BEFORE
2
+ // publishing it; the foundation refuses provisioning/restart of invalid code.
3
+ import { ensureExecutableSignature, type ExecutableSignatureOptions } from '../install/signature.ts'
4
+ import { resolveExecutable } from '../launch/launchd.ts'
5
+ import { type Runtime } from '../core/constants.ts'
6
+ import { readRuntimeManifest, type RuntimeManifest } from './index.ts'
7
+
8
+ export type RuntimeSignatureVerifier = (runtime: Runtime, env: NodeJS.ProcessEnv, manifest?: RuntimeManifest) => void
9
+
10
+ export function verifyRuntimeSignatures(
11
+ runtime: Runtime,
12
+ env: NodeJS.ProcessEnv,
13
+ suppliedManifest?: RuntimeManifest,
14
+ opts: Pick<ExecutableSignatureOptions, 'run' | 'platform'> = {},
15
+ ): void {
16
+ if ((opts.platform ?? process.platform) !== 'darwin') return
17
+ if (!opts.run && (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1')) return
18
+ const manifest = suppliedManifest ?? readRuntimeManifest(runtime, { env })
19
+ const names = new Set<string>()
20
+ const declared = manifest?.peers ?? []
21
+ // The same default launcher the core's provisioning/launch scheme uses. A
22
+ // fully explicit declared-set needs only its explicitly declared launchers.
23
+ if (!declared.length || declared.some(p => !p.runtimeBin)) names.add(`${runtime}-runtime`)
24
+ for (const peer of declared) if (peer.runtimeBin) names.add(peer.runtimeBin)
25
+ const hook = manifest?.selfConfig
26
+ if (hook) names.add(typeof hook === 'string' ? hook : hook.command)
27
+ const checked = new Set<string>()
28
+ for (const name of names) {
29
+ const path = resolveExecutable(name, env)
30
+ if (!path) throw new Error(`runtime "${runtime}" launcher not executable or not found: ${name}`)
31
+ if (checked.has(path)) continue
32
+ checked.add(path)
33
+ ensureExecutableSignature(path, { ...opts, env, repair: false })
34
+ }
35
+ }
@@ -10,8 +10,8 @@
10
10
  // version with a new blurb/self-doc leaves stale descriptions) → restart the
11
11
  // runtime's infra peers via the REGULAR stop/start verbs.
12
12
  //
13
- // The core's own `iapeer update` deliberately does NOT touch runtimes (foundation-
14
- // only — the standing contract; the symmetry is conscious).
13
+ // The CLI's bare `iapeer update` invokes this runtime updater in its cascade;
14
+ // --foundation-only and a foundation version pin omit the runtime leg.
15
15
  //
16
16
  // Version-gate honesty: the installed version comes from the manifest's `version`
17
17
  // stamp (the owners' self-install obligation, telegram 10.06). A manifest WITHOUT
@@ -24,6 +24,7 @@ import { isRuntime, type Runtime } from '../core/constants.ts'
24
24
  import { readPeersIndex } from '../registry/index.ts'
25
25
  import { resolveGlobalRoot } from '../storage/index.ts'
26
26
  import { readRuntimeManifest } from './index.ts'
27
+ import { verifyRuntimeSignatures, type RuntimeSignatureVerifier } from './signature.ts'
27
28
  import {
28
29
  deployRuntime,
29
30
  installRuntimePackage,
@@ -71,6 +72,7 @@ export interface UpdateRuntimeOptions {
71
72
  force?: boolean
72
73
  env?: NodeJS.ProcessEnv
73
74
  runNpx?: NpxRunner
75
+ verifySignatures?: RuntimeSignatureVerifier
74
76
  npmVersion?: NpmVersionFn
75
77
  /** Injectable restart (tests). Default: the regular stop→start verbs, strictly
76
78
  * sequential per peer. */
@@ -124,12 +126,17 @@ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateR
124
126
  }
125
127
  const installed = manifest.version
126
128
  if (installed && installed === latest && !opts.force) {
129
+ try {
130
+ (opts.verifySignatures ?? verifyRuntimeSignatures)(runtime, env)
131
+ } catch (e) {
132
+ return { runtime, package: pkg, state: 'install-failed', from: installed, to: latest, peers: [], restarted: [], detail: e instanceof Error ? e.message : String(e) }
133
+ }
127
134
  return { runtime, package: pkg, state: 'already-latest', from: installed, to: latest, peers: [], restarted: [] }
128
135
  }
129
136
 
130
137
  // Re-install, PINNED to the version the gate resolved (В51 — an unpinned npx can
131
138
  // serve a stale cached/propagating build while we report "updated → latest").
132
- const install = installRuntimePackage({ runtime, package: manifest.package, version: latest, force: true, env, runNpx: opts.runNpx })
139
+ const install = installRuntimePackage({ runtime, package: manifest.package, version: latest, force: true, env, runNpx: opts.runNpx, verifySignatures: opts.verifySignatures })
133
140
  if (install.state !== 'ran') {
134
141
  return { runtime, package: pkg, state: 'install-failed', from: installed, to: latest, peers: [], restarted: [], detail: install.detail ?? install.state }
135
142
  }
@@ -157,7 +164,7 @@ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateR
157
164
  // peers — the deploy is an empty pass, by design.
158
165
  let peers: DeployedPeer[]
159
166
  try {
160
- const d = await deployRuntime({ runtime, env, warn: opts.warn })
167
+ const d = await deployRuntime({ runtime, env, warn: opts.warn, verifySignatures: opts.verifySignatures })
161
168
  peers = d.peers
162
169
  } catch (e) {
163
170
  return { runtime, package: pkg, state: 'deploy-failed', from: installed, to: latest, peers: [], restarted: [], detail: e instanceof Error ? e.message : String(e) }
@@ -26,13 +26,14 @@ import {
26
26
  stripQueries,
27
27
  } from './protocol.ts'
28
28
  import { formatEventLine } from '../storage/rotatelog.ts'
29
- import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry, writePidFile } from './paths.ts'
29
+ import { attachedPath, geometryPath, pidPath, pidStartToken, servePath, sockPath, writeGeometry, writePidFile } from './paths.ts'
30
30
  import { newStabilityGate, nagSignature, nextBootAction, nextNagAction, signatureStable, type BootPredicates, type NagPredicate } from './boot.ts'
31
31
  import { modelToPlainText } from './render.ts'
32
32
  import { codexAdapter } from '../launch/adapters/codex.ts'
33
33
  import { claudeAdapter } from '../launch/adapters/claude.ts'
34
34
  import { approvalModeOf, readPeerProfile, type ApprovalMode } from '../identity/index.ts'
35
35
  import { routeCircuitBreaker } from './approvalRoute.ts'
36
+ import { killPtyDescendants } from './processTree.ts'
36
37
 
37
38
  /** Resolve a runtime launcher from the host AT CALL TIME — env override (IAPEER_<RT>_BIN, the same
38
39
  * knob the rest of the foundation uses) → PATH lookup — never a baked machine-specific path. Returns
@@ -110,6 +111,7 @@ const SIGWINCH_REDRAW_WAIT_MS = 300
110
111
 
111
112
  // Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
112
113
  interface PtyChild {
114
+ pid: number
113
115
  terminal: { write(d: Uint8Array | string): void; resize(cols: number, rows: number): void }
114
116
  kill(sig?: number | string): void
115
117
  }
@@ -385,6 +387,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
385
387
  },
386
388
  },
387
389
  } as unknown as Parameters<typeof Bun.spawn>[1]) as unknown as PtyChild
390
+ const childToken = pidStartToken(child.pid)
388
391
  xterm.onData(resp => child.terminal.write(resp)) // @xterm auto-answers DA1/DSR → back to child
389
392
 
390
393
  /** Catch up a (re)attaching client to the current screen + history ONCE by sending a SNAPSHOT of the
@@ -602,6 +605,14 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
602
605
  } catch {
603
606
  /* */
604
607
  }
608
+ try {
609
+ // self-fresh/stop/reap owns the PTY's descendants, not just its TUI root. Snapshot BEFORE
610
+ // killing the runtime; exclude detached/shared daemons even if they are descendants.
611
+ const cleaned = killPtyDescendants(child.pid, childToken)
612
+ if (cleaned.killed || cleaned.skipped) appendExitEvent({ ev: 'session-tree', identity: session, ...cleaned })
613
+ } catch (e) {
614
+ appendExitEvent({ ev: 'session-tree', identity: session, error: (e as Error).message })
615
+ }
605
616
  try {
606
617
  child.kill('SIGKILL')
607
618
  } catch {
@@ -0,0 +1,57 @@
1
+ // Deterministic stdio process tree: peer -> launcher -> MCP server.
2
+ // No accounts, external services, or installed runtime binaries are used.
3
+ import { spawn } from 'node:child_process'
4
+ import { appendFileSync, writeFileSync } from 'node:fs'
5
+ import { createInterface } from 'node:readline'
6
+
7
+ const [mode, dir, cli] = process.argv.slice(2)
8
+ const self = import.meta.path
9
+ const record = (event: string, extra = {}) => appendFileSync(`${dir}/events.jsonl`, JSON.stringify({ event, pid: process.pid, ppid: process.ppid, ...extra }) + '\n')
10
+ record(mode)
11
+
12
+ if (mode === 'server') {
13
+ // Keep-alive handles after EOF model a server that cannot shut down by EOF alone.
14
+ // The supervisor's hard session teardown must reclaim it, not rely on compliance.
15
+ setInterval(() => {}, 1000)
16
+ process.on('SIGHUP', () => record('server-hup-ignored'))
17
+ process.on('SIGTERM', () => record('server-term-ignored'))
18
+ const input = createInterface({ input: process.stdin })
19
+ input.on('close', () => record('server-eof'))
20
+ input.on('line', line => {
21
+ const req = JSON.parse(line)
22
+ record(req.method)
23
+ if (req.id === undefined) return
24
+ const result = req.method === 'initialize'
25
+ ? { protocolVersion: req.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'owned-stdio', version: '1' } }
26
+ : req.method === 'tools/list'
27
+ ? { tools: [{ name: 'ping', description: 'Return the serving process ID', inputSchema: { type: 'object', properties: {} } }] }
28
+ : { content: [{ type: 'text', text: `pong:${process.pid}` }] }
29
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n')
30
+ })
31
+ } else if (mode === 'wrapper') {
32
+ const server = spawn(process.execPath, [self, 'server', dir], { stdio: 'inherit' })
33
+ process.on('SIGTERM', () => record('wrapper-term-ignored'))
34
+ process.on('SIGHUP', () => record('wrapper-hup-ignored'))
35
+ server.on('exit', () => process.exit(0))
36
+ } else if (mode === 'peer') {
37
+ const sentinel = spawn('sleep', ['60'], { detached: true, stdio: 'ignore' })
38
+ sentinel.unref()
39
+ record('detached-sentinel', { pid: sentinel.pid })
40
+ const wrapper = spawn(process.execPath, [self, 'wrapper', dir], { stdio: ['pipe', 'pipe', 'inherit'] })
41
+ const input = createInterface({ input: wrapper.stdout })
42
+ const send = (id: number, method: string, params: unknown = {}) => wrapper.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n')
43
+ input.on('line', line => {
44
+ const response = JSON.parse(line)
45
+ if (response.id === 1) send(2, 'tools/list')
46
+ if (response.id === 2) send(3, 'tools/call', { name: 'ping', arguments: {} })
47
+ if (response.id === 3) {
48
+ writeFileSync(`${dir}/ready.json`, JSON.stringify({ peer: process.pid, wrapper: wrapper.pid, result: response.result }))
49
+ console.log('› ready')
50
+ }
51
+ })
52
+ send(1, 'initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test-peer', version: '1' } })
53
+ // This is an actual child self-call to the source CLI, not killSession in disguise.
54
+ createInterface({ input: process.stdin }).on('line', line => {
55
+ if (line.replace(/\x1b\[20[01]~/g, '').trim() === 'fresh') spawn(process.execPath, [cli, 'self-fresh'], { stdio: 'inherit', env: process.env })
56
+ })
57
+ }
@@ -0,0 +1,71 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ export interface ProcessRecord { pid: number; ppid: number; tty: string; token: string }
4
+
5
+ /** No argv/env in this snapshot: ancestry + controlling PTY, not names or groups, own cleanup. */
6
+ export function parseProcessTable(table: string): ProcessRecord[] {
7
+ return table.split('\n').flatMap(line => {
8
+ const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line)
9
+ if (!m || Number(m[1]) <= 0) return []
10
+ return [{ pid: Number(m[1]), ppid: Number(m[2]), tty: m[3], token: m[4] }]
11
+ })
12
+ }
13
+
14
+ function processTable(pid?: number): ProcessRecord[] {
15
+ const args = pid === undefined ? ['-axo', 'pid=,ppid=,tty=,lstart='] : ['-o', 'pid=,ppid=,tty=,lstart=', '-p', String(pid)]
16
+ const r = spawnSync('/bin/ps', args, { encoding: 'utf8', env: { ...process.env, LC_ALL: 'C' } })
17
+ if (r.error || r.status !== 0) throw new Error('could not read process ancestry for session teardown')
18
+ return parseProcessTable(r.stdout)
19
+ }
20
+
21
+ /** Capture BEFORE killing the runtime: afterwards stdio launchers may already have PPID 1.
22
+ * The controlling PTY is the ownership boundary: separate process groups (Codex stdio MCP) remain
23
+ * inside it, whereas setsid/detached services (possibly shared with other clients) leave it. Prune
24
+ * those entire branches. Never signal a whole group: only proven PTY descendants whose start token
25
+ * still matches are ours. This preserves the existing HARD session teardown semantics; it is not
26
+ * a replacement for an MCP client's graceful EOF protocol.
27
+ * Already-reparented children and children racing the snapshot are outside this ancestry snapshot. */
28
+ export function killPtyDescendants(root: number, rootToken: string | null, deps: {
29
+ table?: () => ProcessRecord[]
30
+ identity?: (pid: number) => Pick<ProcessRecord, 'token' | 'tty'> | null
31
+ signal?: (pid: number) => void
32
+ } = {}): { killed: number; skipped: number } {
33
+ if (!Number.isInteger(root) || root <= 1 || root === process.pid) throw new Error('invalid hosted runtime pid')
34
+ const rows = (deps.table ?? processTable)()
35
+ // The root may already have died (terminal.exit), even before this snapshot. Do not
36
+ // adopt an unrelated tree on PID reuse; a missing root needs no ancestry cleanup.
37
+ const currentRoot = rows.find(r => r.pid === root)
38
+ if (!currentRoot) return { killed: 0, skipped: 0 }
39
+ if (!rootToken || currentRoot.token !== rootToken) return { killed: 0, skipped: 1 }
40
+ if (['', '-', '??', '?'].includes(currentRoot.tty)) return { killed: 0, skipped: 1 }
41
+ const children = new Map<number, ProcessRecord[]>()
42
+ for (const row of rows) {
43
+ if (!children.has(row.ppid)) children.set(row.ppid, [])
44
+ children.get(row.ppid)!.push(row)
45
+ }
46
+ const seen = new Set([root, process.pid])
47
+ const owned: ProcessRecord[] = []
48
+ const visit = (parent: number) => {
49
+ for (const row of children.get(parent) ?? []) {
50
+ if (seen.has(row.pid)) continue
51
+ if (row.tty !== currentRoot.tty) continue // detached daemon and everything it owns are not this PTY's
52
+ seen.add(row.pid)
53
+ visit(row.pid)
54
+ owned.push(row) // leaves first, while ancestry is still available
55
+ }
56
+ }
57
+ visit(root)
58
+ let killed = 0, skipped = 0
59
+ for (const row of owned) {
60
+ // Unlike killing the root by its Bun subprocess handle, a descendant is a ps-derived PID.
61
+ // An unreadable token is NOT permission to signal it; fail closed on reuse / uncertainty.
62
+ let current: Pick<ProcessRecord, 'token' | 'tty'> | null
63
+ try { current = (deps.identity ?? (pid => processTable(pid)[0] ?? null))(row.pid) } catch { current = null }
64
+ if (current?.token !== row.token || current.tty !== row.tty) { skipped++; continue }
65
+ try {
66
+ (deps.signal ?? (pid => process.kill(pid, 'SIGKILL')))(row.pid)
67
+ killed++
68
+ } catch { /* exited between verification and signal */ }
69
+ }
70
+ return { killed, skipped }
71
+ }
@@ -0,0 +1,163 @@
1
+ // IAP attachment ownership — copy-on-send into recipient-owned foundation state.
2
+ //
3
+ // A caller-supplied attachment path is a SOURCE, never the delivered object. Before
4
+ // routeSend builds an envelope, each regular file is copied byte-for-byte into:
5
+ //
6
+ // <IAPEER_ROOT>/state/iapeer/attachments/<recipient>/<sha256>/<basename>
7
+ //
8
+ // and only that durable path enters the envelope. The content hash makes repeat
9
+ // sends idempotent without coupling the copy to an ephemeral message/queue entry;
10
+ // a recipient can forward its copy and the next route creates a separate copy in
11
+ // the next recipient's inbox. Completed copies are STATE (not cache): no age GC.
12
+ // The recipient's explicit peer removal purges its complete inbox.
13
+
14
+ import {
15
+ chmodSync,
16
+ closeSync,
17
+ existsSync,
18
+ fstatSync,
19
+ fsyncSync,
20
+ mkdirSync,
21
+ openSync,
22
+ readSync,
23
+ renameSync,
24
+ rmSync,
25
+ writeSync,
26
+ } from 'node:fs'
27
+ import { createHash, randomUUID } from 'node:crypto'
28
+ import { basename, join } from 'node:path'
29
+ import { STATE_DIR, isValidName } from '../core/constants.ts'
30
+ import { IapError, err, ok, type Result } from '../core/errors.ts'
31
+ import { resolveGlobalRoot } from '../storage/index.ts'
32
+
33
+ const DIR_MODE = 0o700
34
+ const FILE_MODE = 0o600
35
+ const COPY_BUFFER_BYTES = 1024 * 1024
36
+
37
+ export const ATTACHMENT_SPOOL_DIR = 'attachments'
38
+
39
+ export interface SpooledAttachments {
40
+ paths: string[]
41
+ sha256: string[]
42
+ }
43
+
44
+ export interface AttachmentPurgeResult {
45
+ path: string
46
+ removed: boolean
47
+ }
48
+
49
+ export function attachmentSpoolRoot(env: NodeJS.ProcessEnv = process.env): string {
50
+ return join(resolveGlobalRoot(env), STATE_DIR, 'iapeer', ATTACHMENT_SPOOL_DIR)
51
+ }
52
+
53
+ export function attachmentInboxDir(
54
+ recipient: string,
55
+ env: NodeJS.ProcessEnv = process.env,
56
+ ): string {
57
+ if (!isValidName(recipient)) {
58
+ throw new IapError(`invalid attachment recipient "${recipient}"`)
59
+ }
60
+ return join(attachmentSpoolRoot(env), recipient)
61
+ }
62
+
63
+ /**
64
+ * Copy one source through an fsynced staging file while calculating its SHA-256.
65
+ * The staging file lives in the recipient inbox (same filesystem as the final
66
+ * path), so the final rename is atomic. A partial copy is never exposed in an IAP
67
+ * envelope and is cleaned on every failure visible to this process.
68
+ */
69
+ function copyOne(
70
+ source: string,
71
+ recipient: string,
72
+ env: NodeJS.ProcessEnv,
73
+ ): { path: string; sha256: string } {
74
+ const inbox = attachmentInboxDir(recipient, env)
75
+ mkdirSync(inbox, { recursive: true, mode: DIR_MODE })
76
+ const tmp = join(inbox, `.incoming-${process.pid}-${randomUUID()}`)
77
+ let sourceFd: number | undefined
78
+ let targetFd: number | undefined
79
+ try {
80
+ sourceFd = openSync(source, 'r')
81
+ const sourceStat = fstatSync(sourceFd)
82
+ if (!sourceStat.isFile()) throw new Error('source is not a regular file')
83
+
84
+ targetFd = openSync(tmp, 'wx', FILE_MODE)
85
+ const hash = createHash('sha256')
86
+ const buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES)
87
+ while (true) {
88
+ const read = readSync(sourceFd, buffer, 0, buffer.length, null)
89
+ if (read === 0) break
90
+ hash.update(buffer.subarray(0, read))
91
+ let written = 0
92
+ while (written < read) {
93
+ const n = writeSync(targetFd, buffer, written, read - written)
94
+ if (n <= 0) {
95
+ throw new Error(`write made no progress (${written}/${read} bytes in current chunk)`)
96
+ }
97
+ written += n
98
+ }
99
+ }
100
+ fsyncSync(targetFd)
101
+ closeSync(targetFd)
102
+ targetFd = undefined
103
+ closeSync(sourceFd)
104
+ sourceFd = undefined
105
+
106
+ const sha256 = hash.digest('hex')
107
+ const hashDir = join(inbox, sha256)
108
+ mkdirSync(hashDir, { recursive: true, mode: DIR_MODE })
109
+ const path = join(hashDir, basename(source))
110
+ // Atomic publish. Replacing an existing same-hash+basename object is safe and
111
+ // repairs a manually damaged copy; concurrent publishers carry identical bytes.
112
+ renameSync(tmp, path)
113
+ chmodSync(path, FILE_MODE)
114
+ return { path, sha256 }
115
+ } finally {
116
+ if (targetFd !== undefined) {
117
+ try { closeSync(targetFd) } catch { /* already closed */ }
118
+ }
119
+ if (sourceFd !== undefined) {
120
+ try { closeSync(sourceFd) } catch { /* already closed */ }
121
+ }
122
+ try { rmSync(tmp, { force: true }) } catch { /* renamed or best-effort cleanup */ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Materialize every attachment in the target peer's inbox. All validation and
128
+ * copying completes before the caller receives paths for envelope construction;
129
+ * any failure aborts the send loudly. Copies already completed before a later
130
+ * item fails stay as harmless recipient state and are content-address deduplicated
131
+ * on retry (removing them here could delete a copy referenced by an earlier send).
132
+ */
133
+ export function spoolAttachments(
134
+ sources: readonly string[],
135
+ recipient: string,
136
+ env: NodeJS.ProcessEnv = process.env,
137
+ ): Result<SpooledAttachments> {
138
+ const paths: string[] = []
139
+ const sha256: string[] = []
140
+ for (let i = 0; i < sources.length; i++) {
141
+ const source = sources[i]!
142
+ try {
143
+ const copied = copyOne(source, recipient, env)
144
+ paths.push(copied.path)
145
+ sha256.push(copied.sha256)
146
+ } catch (e) {
147
+ const reason = e instanceof Error ? e.message : String(e)
148
+ return err(`attachment ${i + 1} "${source}" could not be copied for "${recipient}": ${reason}; message NOT delivered`)
149
+ }
150
+ }
151
+ return ok({ paths, sha256 })
152
+ }
153
+
154
+ /** Recipient-lifetime cleanup: called by `iapeer remove <peer>`. */
155
+ export function purgeAttachmentInbox(
156
+ recipient: string,
157
+ env: NodeJS.ProcessEnv = process.env,
158
+ ): AttachmentPurgeResult {
159
+ const path = attachmentInboxDir(recipient, env)
160
+ const removed = existsSync(path)
161
+ rmSync(path, { recursive: true, force: true })
162
+ return { path, removed }
163
+ }
@@ -71,6 +71,17 @@ import {
71
71
  } from './originGuard.ts'
72
72
  import { keysToBytes } from '../supervisor/protocol.ts' // pure send-keys-vocab→pty-byte translation (boot-driver slice a), @xterm-free
73
73
  import { paneLogComposerOccupied } from '../launch/readyGateModel.ts' // spawn-flip Ф0b-3 slice 3c: hosted busy-composer detector (slice 2). @xterm dynamic-loaded inside it → warm path stays @xterm-free with the flip off
74
+ import { spoolAttachments } from './attachments.ts'
75
+
76
+ export {
77
+ ATTACHMENT_SPOOL_DIR,
78
+ attachmentInboxDir,
79
+ attachmentSpoolRoot,
80
+ purgeAttachmentInbox,
81
+ spoolAttachments,
82
+ type AttachmentPurgeResult,
83
+ type SpooledAttachments,
84
+ } from './attachments.ts'
74
85
 
75
86
  export interface OnlinePeer {
76
87
  personality: string
@@ -1502,8 +1513,11 @@ async function routeSendInner(
1502
1513
  // Origin-guard (docs/18) — an agent's PRESUMED REPLY to a human (the human's latest
1503
1514
  // inbound to this agent is unanswered) must target the channel the human wrote from.
1504
1515
  // Checked FIRST, before any resolve/wake side-effect and before the telegram policy:
1505
- // a mismatch is HELD (persisted verbatim + instructive error), not delivered — the
1506
- // agent then confirms cross-channel or redirects to the origin, zero regeneration.
1516
+ // a mismatch is HELD, not delivered — the agent then confirms cross-channel or
1517
+ // redirects to the origin, zero regeneration. Attachment PATHS are the deliberate
1518
+ // exception to "verbatim": sources are copied into the intended recipient's durable
1519
+ // inbox before the hold is persisted, so deleting a /tmp source cannot break a later
1520
+ // confirm-send. Message/topic/runtime remain verbatim.
1507
1521
  // Intended runtime = the explicit override or the human's default; exact for the
1508
1522
  // natural-peer class (implicit routing is strictly default_runtime-anchored).
1509
1523
  // Initiative (answered / no inbound / ARM_TTL-stale) passes untouched.
@@ -1516,9 +1530,11 @@ async function routeSendInner(
1516
1530
  const armed = armedOrigin(caller.personality, peer.personality, { env })
1517
1531
  const intendedRt = runtime ?? peer.runtime
1518
1532
  if (armed && armed.rt !== intendedRt) {
1533
+ const spooled = spoolAttachments(attachmentsResult.value, peer.personality, env)
1534
+ if (!spooled.ok) return spooled
1519
1535
  const held = holdSend(
1520
1536
  caller.address,
1521
- { personality, runtime, message, topic, attachments: attachmentsResult.value },
1537
+ { personality, runtime, message, topic, attachments: spooled.value.paths },
1522
1538
  armed,
1523
1539
  intendedRt,
1524
1540
  { env },
@@ -1533,6 +1549,14 @@ async function routeSendInner(
1533
1549
  const intendedGuard = telegramSenderGuard(caller, runtime ?? peer.runtime, personality)
1534
1550
  if (!intendedGuard.ok) return intendedGuard
1535
1551
 
1552
+ // Attachment ownership boundary: caller paths are SOURCES only. Materialize
1553
+ // recipient-owned, content-addressed copies BEFORE building any live/wake/queue
1554
+ // envelope. Every path below (including ephemeral/composer queues and bridge
1555
+ // runtimes) therefore has the same durability semantics. Copy failure is a
1556
+ // synchronous NOT-delivered error; no route side effect has happened yet.
1557
+ const spooled = spoolAttachments(attachmentsResult.value, peer.personality, env)
1558
+ if (!spooled.ok) return spooled
1559
+
1536
1560
  // Stamped ONCE at router acceptance: the same instant travels as the envelope's
1537
1561
  // `ts` attribute (recipient side) AND is returned as the result's `ts` (sender
1538
1562
  // side) — the two correspond literally, which is what makes async-desync
@@ -1547,7 +1571,7 @@ async function routeSendInner(
1547
1571
  fromIntelligence: caller.intelligence,
1548
1572
  sentAt,
1549
1573
  topic,
1550
- attachments: attachmentsResult.value,
1574
+ attachments: spooled.value.paths,
1551
1575
  message,
1552
1576
  })
1553
1577
 
@@ -36,7 +36,8 @@ import { join } from 'path'
36
36
  import { isInfraRuntime } from '../core/constants.ts'
37
37
  import { IapError } from '../core/errors.ts'
38
38
  import { IAPEER_VERSION } from '../core/version.ts'
39
- import { stampBinaryHealthy } from '../install/index.ts'
39
+ import { iapeerBinPath, stampBinaryHealthy } from '../install/index.ts'
40
+ import { ensureExecutableSignature } from '../install/signature.ts'
40
41
  import { readPeersIndex } from '../registry/index.ts'
41
42
  import {
42
43
  cycleDaemon,
@@ -71,6 +72,9 @@ export interface UpdateDeps {
71
72
  /** Pull + rebuild the binary for `version` (default: npm pack → extract → source install).
72
73
  * Returns true on success. */
73
74
  runInstall?: (version: string, env: NodeJS.ProcessEnv) => boolean
75
+ /** Verify-only installed-binary gate, including same-version stale-daemon heal.
76
+ * Never repair a live binary. The package installer owns staged repair. */
77
+ verifyInstalledSignature?: (env: NodeJS.ProcessEnv) => void
74
78
  /** Restart the daemon onto the new binary (default: cycleDaemon — bootout+bootstrap, the LWCR-safe cycle). */
75
79
  restartDaemon?: (env: NodeJS.ProcessEnv) => DaemonRestartResult
76
80
  /** Re-register loaded foundation-owned infra launchd jobs whose plists run the
@@ -345,7 +349,20 @@ export async function updateIapeer(deps: UpdateDeps = {}): Promise<UpdateResult>
345
349
  : `could not resolve the latest ${IAPEER_PACKAGE} version from npm (offline / registry error)`,
346
350
  }
347
351
  }
352
+ const verifyInstalled = (): string | undefined => {
353
+ try {
354
+ if (deps.verifyInstalledSignature) deps.verifyInstalledSignature(env)
355
+ else ensureExecutableSignature(iapeerBinPath(env), { env, repair: false })
356
+ return undefined
357
+ } catch (e) {
358
+ return `installed foundation signature is invalid; no restart attempted: ${e instanceof Error ? e.message : String(e)}`
359
+ }
360
+ }
348
361
  if (desired === from && !deps.force) {
362
+ // Same-version heal is an activation path too: a valid running inode/version
363
+ // does not prove the current on-disk binary is safe to restart.
364
+ const signatureFailure = verifyInstalled()
365
+ if (signatureFailure) return { status: 'failed', from, latest: desired, reason: signatureFailure }
349
366
  // В53 — the binary is at the target, but is the LIVE daemon running it? A prior
350
367
  // update that swapped the binary and then died before/inside cycleDaemon leaves
351
368
  // the fleet on the old core FOREVER while every retry says "already-latest".
@@ -384,6 +401,9 @@ export async function updateIapeer(deps: UpdateDeps = {}): Promise<UpdateResult>
384
401
  }
385
402
  }
386
403
 
404
+ const signatureFailure = verifyInstalled()
405
+ if (signatureFailure) return { status: 'failed', from, latest: desired, reason: signatureFailure }
406
+
387
407
  const d = (deps.restartDaemon ?? cycleDaemon)(env)
388
408
  const settled = await settleDaemon(d, env, deps)
389
409
  return {