@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.
- package/CHANGELOG.md +27 -0
- package/docs/04-lifecycle-and-daemon.md +11 -0
- package/docs/06-storage.md +2 -1
- package/docs/07-install-and-update.md +3 -3
- package/docs/10-messaging.md +12 -0
- package/docs/12-cli-reference.md +1 -1
- package/docs/13-architecture-and-contract.md +44 -0
- package/docs/15-fleet-api.md +2 -2
- package/docs/18-origin-guard.md +2 -2
- 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
- package/docs/ru/06-/321/205/321/200/320/260/320/275/320/265/320/275/320/270/320/265.md +2 -1
- 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
- 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
- package/docs/ru/12-cli-reference.md +1 -1
- 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
- package/docs/ru/15-fleet-api.md +2 -2
- package/docs/ru/18-origin-guard.md +2 -2
- package/package.json +4 -2
- package/scripts/proof-install-signature.ts +189 -0
- package/src/cli/index.ts +25 -3
- package/src/daemon/fleet.ts +8 -1
- package/src/daemon/index.ts +1 -1
- package/src/install/index.ts +41 -16
- package/src/install/signature.ts +94 -0
- package/src/install/signing.ts +7 -7
- package/src/launch/adapters/claude.ts +7 -0
- package/src/launch/launchdRun.ts +1 -1
- package/src/launch/ptyHost.ts +1 -1
- package/src/lifecycle/index.ts +1 -1
- package/src/runtime/deploy.ts +21 -0
- package/src/runtime/signature.ts +35 -0
- package/src/runtime/update.ts +11 -4
- package/src/supervisor/daemon.ts +12 -1
- package/src/supervisor/fixtures/owned-stdio.ts +57 -0
- package/src/supervisor/processTree.ts +71 -0
- package/src/transport/attachments.ts +163 -0
- package/src/transport/index.ts +28 -4
- package/src/update/index.ts +21 -1
package/src/runtime/deploy.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/runtime/update.ts
CHANGED
|
@@ -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
|
|
14
|
-
// only
|
|
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) }
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/transport/index.ts
CHANGED
|
@@ -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
|
|
1506
|
-
//
|
|
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:
|
|
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:
|
|
1574
|
+
attachments: spooled.value.paths,
|
|
1551
1575
|
message,
|
|
1552
1576
|
})
|
|
1553
1577
|
|
package/src/update/index.ts
CHANGED
|
@@ -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 {
|