@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
@@ -0,0 +1,189 @@
1
+ // Native artifact acceptance, NOT a host deploy. Run from the unpacked npm package:
2
+ // bun scripts/proof-install-signature.ts [package-root]
3
+ // Real CLI installer/compiler/codesign; launchd update seams are explicitly isolated.
4
+ import { createHash } from 'crypto'
5
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
6
+ import { homedir, tmpdir } from 'os'
7
+ import { join, resolve } from 'path'
8
+ import { spawnSync } from 'child_process'
9
+ import { pathToFileURL } from 'url'
10
+
11
+ if (process.platform !== 'darwin') throw new Error('native signature proof requires macOS')
12
+ if (process.env.IAPEER_TEST_SANDBOX === '1') throw new Error('native proof must execute real codesign; run outside bun test')
13
+ const identity = spawnSync('/usr/bin/security', ['find-identity', '-p', 'codesigning'], { encoding: 'utf8' })
14
+ if (identity.status !== 0 || !identity.stdout.includes('"iapeer Local Codesign"')) {
15
+ throw new Error('proof requires the EXISTING local identity; it does not create one')
16
+ }
17
+
18
+ const pkg = resolve(process.argv[2] ?? join(import.meta.dir, '..'))
19
+ const fromPackage = (path: string) => import(pathToFileURL(join(pkg, path)).href)
20
+ const { installIapeer, rollbackIapeer, stampBinaryHealthy } = await fromPackage('src/install/index.ts')
21
+ const { ensureExecutableSignature } = await fromPackage('src/install/signature.ts')
22
+ const { updateIapeer } = await fromPackage('src/update/index.ts')
23
+ const { installRuntimePackage } = await fromPackage('src/runtime/deploy.ts')
24
+ const { updateRuntime } = await fromPackage('src/runtime/update.ts')
25
+ const { writeRuntimeManifest, runtimeManifestPath } = await fromPackage('src/runtime/index.ts')
26
+ const expectedVersion = JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')).version
27
+ const root = mkdtempSync(join(tmpdir(), 'iapeer-native-signature-'))
28
+ const tools = join(root, 'tools')
29
+ const faultyTools = join(root, 'faulty-tools')
30
+ const tray = join(root, 'tray')
31
+ for (const dir of [tools, faultyTools, tray, join(root, 'home')]) mkdirSync(dir, { recursive: true })
32
+ const env = {
33
+ ...process.env,
34
+ HOME: join(root, 'home'),
35
+ IAPEER_ROOT: join(root, 'state'),
36
+ IAPEER_BIN_DIR: join(root, 'bin'),
37
+ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
38
+ IAPEER_SOCK_DIR: join(root, 'sockets'),
39
+ IAPEER_PROOF_SWIFTBAR_DIR: tray,
40
+ PATH: `${tools}:${process.env.PATH ?? ''}`,
41
+ }
42
+ // SwiftBar preferences are host-global even with HOME overridden. The stock install
43
+ // discovers a pre-existing plugin dir through this isolated read-only defaults shim.
44
+ writeFileSync(join(tools, 'defaults'), '#!/bin/sh\nif [ "$1" = read ]; then printf "%s\\n" "$IAPEER_PROOF_SWIFTBAR_DIR"; else echo "proof refuses defaults write" >&2; exit 71; fi\n', { mode: 0o755 })
45
+ const quote = (s: string) => `'${s.replaceAll("'", "'\\''")}'`
46
+ // A disposable HOME otherwise hides the existing login keychain from security.
47
+ // Permit its read/sign operations on our staged files, but refuse identity imports.
48
+ const hostHome = process.env.HOME || homedir()
49
+ writeFileSync(join(tools, 'security'), `#!/bin/sh
50
+ if [ "$1" != find-identity ]; then echo 'proof refuses keychain mutation' >&2; exit 71; fi
51
+ export HOME=${quote(hostHome)}
52
+ exec /usr/bin/security "$@"
53
+ `, { mode: 0o755 })
54
+ writeFileSync(join(tools, 'codesign'), `#!/bin/sh
55
+ export HOME=${quote(hostHome)}
56
+ exec /usr/bin/codesign "$@"
57
+ `, { mode: 0o755 })
58
+ // Fault only the compiler's OUTPUT, not signing or the installer. CLI execution is
59
+ // forwarded to the real Bun; a build exits zero with a malformed staged Mach-O.
60
+ writeFileSync(join(faultyTools, 'bun'), `#!/bin/sh
61
+ if [ "$1" = build ]; then
62
+ while [ "$#" -gt 0 ]; do
63
+ if [ "$1" = --outfile ]; then shift; dd if="$IAPEER_BIN_DIR/iapeer" of="$1" bs=4096 count=1 2>/dev/null; chmod +x "$1"; exit 0; fi
64
+ shift
65
+ done
66
+ exit 72
67
+ fi
68
+ exec ${quote(process.execPath)} "$@"
69
+ `, { mode: 0o755 })
70
+ const faultyEnv = { ...env, PATH: `${faultyTools}:${env.PATH}` }
71
+ const checks: string[] = []
72
+ const check = (ok: boolean, label: string) => {
73
+ if (!ok) throw new Error(`FAIL: ${label}`)
74
+ checks.push(label)
75
+ console.log(`PASS: ${label}`)
76
+ }
77
+ const hash = (path: string) => createHash('sha256').update(readFileSync(path)).digest('hex')
78
+ const installCli = (passedEnv = env) => {
79
+ const result = spawnSync('/bin/bash', [join(pkg, 'bin/iapeer'), 'install'], {
80
+ encoding: 'utf8', env: passedEnv, cwd: pkg, timeout: 180_000,
81
+ })
82
+ console.log(result.stdout)
83
+ if (result.stderr) console.log(result.stderr)
84
+ return result
85
+ }
86
+ const bin = join(env.IAPEER_BIN_DIR, 'iapeer')
87
+ const execute = () => {
88
+ const result = spawnSync(bin, ['version'], { encoding: 'utf8', env, timeout: 30_000 })
89
+ check(result.status === 0 && result.stdout.trim() === expectedVersion, 'compiled binary executes expected version')
90
+ }
91
+ try {
92
+ let result = installCli()
93
+ check(result.status === 0 && result.stdout.includes('signing: signed'), 'stock CLI full-payload install succeeds with existing stable identity')
94
+ check(ensureExecutableSignature(bin, { env, repair: false }).state === 'verified', 'installed inode strictly verifies')
95
+ execute()
96
+ check(stampBinaryHealthy(env), 'isolated healthy stamp')
97
+ result = installCli()
98
+ check(result.status === 0, 'stock CLI reinstall succeeds')
99
+ check(stampBinaryHealthy(env), 'isolated healthy stamp after reinstall')
100
+ // Foundation has no runtime manifest; its binary/config pair is binary + daemon
101
+ // plist. Include rollback bytes and healthy stamp in every refusal comparison.
102
+ const paths = [bin, `${bin}.prev`, `${bin}.healthy`, join(env.IAPEER_LAUNCHAGENTS_DIR, 'com.agfpd.iapeer.plist')]
103
+ const snapshot = () => paths.map(hash)
104
+ const unchanged = (before: string[], label: string) => {
105
+ check(snapshot().every((v, i) => v === before[i]), label)
106
+ check(!existsSync(`${bin}.tmp`), 'rejected staged file removed')
107
+ execute()
108
+ }
109
+ const beforeRefusal = snapshot()
110
+ result = installCli(faultyEnv)
111
+ check(result.status !== null && result.status !== 0 && `${result.stdout}${result.stderr}`.includes('executable signature'), 'stock CLI rejects compiler output through REAL codesign')
112
+ unchanged(beforeRefusal, 'CLI refusal preserves binary/.prev/.healthy/daemon-plist byte-for-byte')
113
+
114
+ let restarted = 0
115
+ let recycled = 0
116
+ const update = (passedEnv: typeof env) => updateIapeer({
117
+ env: passedEnv, currentVersion: expectedVersion, force: true,
118
+ resolveVersion: () => expectedVersion,
119
+ runInstall: () => installCli(passedEnv).status === 0,
120
+ // No launchctl commands or real daemon health claims in native artifact proof.
121
+ restartDaemon: () => { restarted++; return { state: 'restarted' } },
122
+ waitHealthy: async () => ({ healthy: true }),
123
+ recycleInfraJobs: () => { recycled++; return [] },
124
+ stampHealthy: () => stampBinaryHealthy(env),
125
+ })
126
+ const failedUpdate = await update(faultyEnv)
127
+ check(failedUpdate.status === 'failed' && restarted === 0 && recycled === 0, 'update install refusal prevents ALL lifecycle activation calls')
128
+ unchanged(beforeRefusal, 'update refusal preserves binary/config/rollback pair')
129
+ const successfulUpdate = await update(env)
130
+ check(successfulUpdate.status === 'updated' && restarted === 1 && recycled === 1, 'real successful installer passes update activation gate (lifecycle seams isolated)')
131
+ check(ensureExecutableSignature(bin, { env, repair: false }).state === 'verified', 'updated inode strictly verifies')
132
+ execute()
133
+
134
+ const sameVersionEnv = { ...env, IAPEER_BIN_DIR: join(root, 'same-version-bin') }
135
+ mkdirSync(sameVersionEnv.IAPEER_BIN_DIR)
136
+ const sameVersionBin = join(sameVersionEnv.IAPEER_BIN_DIR, 'iapeer')
137
+ copyFileSync(bin, sameVersionBin)
138
+ const sameVersion = () => updateIapeer({
139
+ env: sameVersionEnv, currentVersion: expectedVersion, resolveVersion: () => expectedVersion,
140
+ liveDaemonVersion: () => expectedVersion,
141
+ })
142
+ check((await sameVersion()).status === 'already-latest', 'same-version foundation update passes REAL verify-only gate')
143
+ writeFileSync(sameVersionBin, Buffer.from([0xcf, 0xfa, 0xed, 0xfe]))
144
+ const invalidBefore = hash(sameVersionBin)
145
+ const invalidSameVersion = await updateIapeer({
146
+ env: sameVersionEnv, currentVersion: expectedVersion, resolveVersion: () => expectedVersion,
147
+ liveDaemonVersion: () => '0.0.0',
148
+ restartDaemon: () => { throw new Error('unexpected stale-daemon activation') },
149
+ })
150
+ check(invalidSameVersion.status === 'failed' && invalidSameVersion.reason.includes('signature'), 'invalid same-version foundation cannot activate stale-daemon recovery')
151
+ check(hash(sameVersionBin) === invalidBefore, 'foundation verify-only gate leaves installed bytes unchanged')
152
+
153
+ const tiny = join(root, 'tiny.ts')
154
+ writeFileSync(tiny, 'console.log("tiny-signature-proof")\n')
155
+ const beforeTiny = snapshot()
156
+ let tinyError: unknown
157
+ try { installIapeer(tiny, env) } catch (e) { tinyError = e }
158
+ if (tinyError) {
159
+ console.log(`INFO: real tiny-source build rejected: ${String(tinyError)}`)
160
+ unchanged(beforeTiny, 'real tiny-source refusal preserves binary/config/rollback pair')
161
+ } else {
162
+ console.log('INFO: tiny-source output is signable on this compiler; restoring full CLI')
163
+ check(installCli().status === 0, 'restore full CLI after signable tiny-source build')
164
+ check(stampBinaryHealthy(env), 'restored healthy stamp')
165
+ }
166
+
167
+ // Secondary core gate: real installed launcher signature, no provisioning or
168
+ // restart. A custom manifest also exercises package resolution outside the map.
169
+ const launcher = join(env.IAPEER_BIN_DIR, 'proof-runtime')
170
+ copyFileSync(bin, launcher)
171
+ writeRuntimeManifest({ runtime: 'proof', package: '@proof/runtime', version: '1.0.0', peers: [] }, { env })
172
+ const unexpected = () => { throw new Error('unexpected runtime install/restart') }
173
+ const runtimeEnv = { ...env, PATH: `${env.IAPEER_BIN_DIR}:${env.PATH}` }
174
+ check(installRuntimePackage({ runtime: 'proof', env: runtimeEnv, runNpx: unexpected }).state === 'skipped', 'already-installed runtime passes REAL verify-only gate')
175
+ check((await updateRuntime({ runtime: 'proof', env: runtimeEnv, npmVersion: () => '1.0.0', restartPeer: unexpected, runNpx: unexpected })).state === 'already-latest', 'already-latest runtime passes REAL verify-only gate')
176
+ const manifestPath = runtimeManifestPath('proof', { env })
177
+ writeFileSync(launcher, Buffer.from([0xcf, 0xfa, 0xed, 0xfe]))
178
+ const rejectedPair = [hash(launcher), hash(manifestPath)]
179
+ check(installRuntimePackage({ runtime: 'proof', env: runtimeEnv, runNpx: unexpected }).state === 'failed', 'already-installed runtime rejects invalid launcher')
180
+ check((await updateRuntime({ runtime: 'proof', env: runtimeEnv, npmVersion: () => '1.0.0', restartPeer: unexpected, runNpx: unexpected })).state === 'install-failed', 'already-latest runtime rejects invalid launcher before restart')
181
+ check(hash(launcher) === rejectedPair[0] && hash(manifestPath) === rejectedPair[1], 'verify-only runtime gate preserves supplied binary/manifest without repair')
182
+
183
+ check(rollbackIapeer(env).status === 'rolled-back', 'isolated foundation rollback succeeds')
184
+ check(ensureExecutableSignature(bin, { env, repair: false }).state === 'verified', 'rollback inode strictly verifies')
185
+ execute()
186
+ console.log(JSON.stringify({ version: expectedVersion, bun: Bun.version, platform: process.platform, packageRoot: pkg, checks, lifecycle: 'injected: no live restart or daemon-health claim' }, null, 2))
187
+ } finally {
188
+ rmSync(root, { recursive: true, force: true })
189
+ }
package/src/cli/index.ts CHANGED
@@ -26,7 +26,15 @@ import { cascadeTail, recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaem
26
26
  import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
27
27
  import { ensureGlobalIapScaffold } from '../storage/index.ts'
28
28
  import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
29
- import { compactDoneBaseline, isPeerLive, routeControl, routeSend, waitForCompactDone, type WakeFn } from '../transport/index.ts'
29
+ import {
30
+ compactDoneBaseline,
31
+ isPeerLive,
32
+ purgeAttachmentInbox,
33
+ routeControl,
34
+ routeSend,
35
+ waitForCompactDone,
36
+ type WakeFn,
37
+ } from '../transport/index.ts'
30
38
  import {
31
39
  attachPeer,
32
40
  clearIdleReaped,
@@ -888,6 +896,9 @@ export interface RemoveOutcome {
888
896
  * personality inherits the dead namesake's parking (defect: stale .stopped →
889
897
  * `mode=refused cause=stopped` on a freshly-created peer). */
890
898
  purgedState?: string[]
899
+ /** Recipient-owned IAP attachment inbox cleanup. Completed attachment copies
900
+ * are state for the peer lifetime, so explicit peer removal owns their purge. */
901
+ attachmentInbox?: string
891
902
  }
892
903
 
893
904
  /**
@@ -1039,11 +1050,19 @@ export async function removePeerCli(
1039
1050
  // remove never half-purges a still-registered peer.
1040
1051
  const cfg = loadLifecycleConfig(env)
1041
1052
  const purgedState = peer.runtimes.flatMap(rt => purgeIdentityState(cfg, buildProcessAddress(rt, personality)))
1053
+ let attachmentInbox: string | undefined
1054
+ try {
1055
+ const purged = purgeAttachmentInbox(personality, env)
1056
+ if (purged.removed) attachmentInbox = `removed ${purged.path}`
1057
+ } catch (e) {
1058
+ attachmentInbox = `FAILED: ${e instanceof Error ? e.message : String(e)}`
1059
+ }
1042
1060
  return {
1043
1061
  personality,
1044
1062
  action: 'removed',
1045
1063
  cwd: peer.cwd,
1046
1064
  purgedState,
1065
+ ...(attachmentInbox ? { attachmentInbox } : {}),
1047
1066
  ...(plistTeardown ? { plistTeardown } : {}),
1048
1067
  ...(unprovisionOutcomes.length ? { unprovision: unprovisionOutcomes } : {}),
1049
1068
  ...(trustCleaned ? { codexTrust: trustCleaned } : {}),
@@ -1916,8 +1935,8 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1916
1935
  case 'update-runtime': {
1917
1936
  // §(г) runtime-package update: version-gate (npm vs the manifest stamp) →
1918
1937
  // forced re-npx → idempotent re-provision (same path as install-runtime) →
1919
- // restart the runtime's peers via the regular stop/start. The core's own
1920
- // `update` stays foundation-only — this is the runtimes' counterpart.
1938
+ // restart the runtime's peers via the regular stop/start. A bare `update`
1939
+ // calls the same updater as its runtime cascade leg.
1921
1940
  const all = flags.all === true
1922
1941
  if (!all && !positionals[0]) return argErr(errOut, 'update-runtime needs a runtime or --all — usage: iapeer update-runtime <runtime> | --all')
1923
1942
  const { updateRuntime, updateAllRuntimes } = await import('../runtime/update.ts')
@@ -2280,6 +2299,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2280
2299
  if (o.purgedState?.length) {
2281
2300
  out(`lifecycle state purged: ${o.purgedState.join(', ')}\n`)
2282
2301
  }
2302
+ if (o.attachmentInbox) {
2303
+ out(`attachment inbox: ${o.attachmentInbox}\n`)
2304
+ }
2283
2305
  // Deliberate: the registry reap never deletes user data — but SAY so, or
2284
2306
  // the default-location peers leave silent orphan folders.
2285
2307
  if (o.cwd && existsSync(o.cwd)) {
@@ -658,7 +658,13 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
658
658
  return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
659
659
  }
660
660
  if (typeof body.personality !== 'string' || !body.personality || typeof body.message !== 'string' || !body.message) {
661
- return sendJson(res, 400, { error: 'send needs {personality, message} (+ optional runtime/topic/from)' })
661
+ return sendJson(res, 400, { error: 'send needs {personality, message} (+ optional runtime/topic/attachments/from)' })
662
+ }
663
+ if (
664
+ body.attachments !== undefined &&
665
+ (!Array.isArray(body.attachments) || !body.attachments.every(item => typeof item === 'string'))
666
+ ) {
667
+ return sendJson(res, 400, { error: 'send attachments must be an array of absolute local source-file paths' })
662
668
  }
663
669
  let caller: ResolvedCaller
664
670
  try {
@@ -676,6 +682,7 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
676
682
  runtime: typeof body.runtime === 'string' ? body.runtime : undefined,
677
683
  message: body.message,
678
684
  topic: typeof body.topic === 'string' ? body.topic : undefined,
685
+ attachments: Array.isArray(body.attachments) ? body.attachments : undefined,
679
686
  },
680
687
  ctx.deps,
681
688
  )
@@ -163,7 +163,7 @@ export function listTools(): unknown[] {
163
163
  topic: { type: 'string', description: 'Optional short topic for threading related peer messages.', maxLength: MAX_TOPIC_LEN },
164
164
  attachments: {
165
165
  type: 'array',
166
- description: 'Optional absolute local file paths. IAP delivers them as a separate <attachments> field, not as part of message text.',
166
+ description: 'Optional absolute local source-file paths. Before accepting the send, IAP copies each regular file into durable recipient-owned state and puts the copy paths in the separate <attachments> field; the sources may be deleted immediately after success.',
167
167
  maxItems: MAX_ATTACHMENTS,
168
168
  items: { type: 'string' },
169
169
  },
@@ -20,6 +20,9 @@ import { basename, join, relative, sep } from 'path'
20
20
  import { spawnSync } from 'child_process'
21
21
  import { resolveGlobalRoot } from '../storage/index.ts'
22
22
  import { signInstalledBinary, type SigningOutcome } from './signing.ts'
23
+ import { ensureExecutableSignature } from './signature.ts'
24
+ export { ensureExecutableSignature } from './signature.ts'
25
+ export type { ExecutableSignatureOptions, ExecutableSignatureOutcome, SignatureRunner } from './signature.ts'
23
26
 
24
27
  /** The stable host-wide install path of the `iapeer` binary. Standard user-bin (no
25
28
  * admin, not tied to a node/bun version), ON $PATH. The launchd plists reference
@@ -42,8 +45,8 @@ export interface InstallResult {
42
45
  prevKept?: { reason: string }
43
46
  /** Bytes of the installed binary. */
44
47
  size?: number
45
- /** Stable-identity re-sign outcome (TCC grants survive updates). Soft: a signing
46
- * hiccup never fails the install — the binary works ad-hoc-signed. */
48
+ /** Stable-identity re-sign outcome (TCC grants survive updates). Soft failure
49
+ * is allowed only when the separate executable-signature gate succeeds. */
47
50
  signing?: SigningOutcome
48
51
  }
49
52
 
@@ -132,16 +135,38 @@ function assertInstallSandboxIsolated(binPath: string, env: NodeJS.ProcessEnv):
132
135
  }
133
136
  }
134
137
 
135
- export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = process.env): InstallResult {
138
+ /** Hermetic install seams. Paths remain resolved from the caller's env. */
139
+ export interface InstallIapeerDeps {
140
+ build?: (entry: string, tmp: string) => void
141
+ signStable?: typeof signInstalledBinary
142
+ verifySignature?: typeof ensureExecutableSignature
143
+ }
144
+
145
+ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = process.env, deps: InstallIapeerDeps = {}): InstallResult {
136
146
  const binPath = iapeerBinPath(env)
137
147
  assertInstallSandboxIsolated(binPath, env)
138
148
  mkdirSync(join(binPath, '..'), { recursive: true })
139
149
  const tmp = `${binPath}.tmp`
140
- const build = spawnSync('bun', ['build', '--compile', cliEntrypoint, '--outfile', tmp], {
141
- encoding: 'utf8',
142
- })
143
- if (build.status !== 0 || !existsSync(tmp)) {
144
- throw new Error(`iapeer build failed: ${(build.stderr ?? '').trim() || `exit ${build.status}`}`)
150
+ let signing: SigningOutcome
151
+ try {
152
+ if (deps.build) {
153
+ deps.build(cliEntrypoint, tmp)
154
+ } else {
155
+ const build = spawnSync('bun', ['build', '--compile', cliEntrypoint, '--outfile', tmp], {
156
+ encoding: 'utf8',
157
+ })
158
+ if (build.status !== 0 || !existsSync(tmp)) {
159
+ throw new Error(`iapeer build failed: ${(build.stderr ?? '').trim() || `exit ${build.status}`}`)
160
+ }
161
+ }
162
+ // Sign the STAGED inode, then verify before ANY activation/.prev/stamp change.
163
+ // Stable TCC signing remains soft; invalid executable signatures are hard.
164
+ signing = (deps.signStable ?? signInstalledBinary)(tmp, env)
165
+ const verifySignature = deps.verifySignature ?? ensureExecutableSignature
166
+ verifySignature(tmp, { env })
167
+ } catch (e) {
168
+ rmSync(tmp, { force: true })
169
+ throw e
145
170
  }
146
171
  let prevPath: string | undefined
147
172
  let prevKept: InstallResult['prevKept']
@@ -168,9 +193,7 @@ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = pr
168
193
  } catch {
169
194
  /* absent — fine */
170
195
  }
171
- // Stable-identity re-sign (TCC grants survive updates). AFTER the rename: the
172
- // signature belongs to the final inode at the final path. Soft-fail by design.
173
- const signing = signInstalledBinary(binPath, env)
196
+ // rename preserves the already-verified inode and its stable requirement.
174
197
  let size: number | undefined
175
198
  try {
176
199
  size = statSync(binPath).size
@@ -257,7 +280,7 @@ export interface RollbackResult {
257
280
  * rename over the binary), so the binary is never absent. The CALLER restarts the
258
281
  * daemon afterwards (cycleDaemon) — rollback only swaps the bytes. Sandbox-guarded.
259
282
  */
260
- export function rollbackIapeer(env: NodeJS.ProcessEnv = process.env): RollbackResult {
283
+ export function rollbackIapeer(env: NodeJS.ProcessEnv = process.env, deps: Pick<InstallIapeerDeps, 'signStable' | 'verifySignature'> = {}): RollbackResult {
261
284
  const binPath = iapeerBinPath(env)
262
285
  assertInstallSandboxIsolated(binPath, env)
263
286
  const prev = iapeerPrevBinPath(env)
@@ -267,13 +290,15 @@ export function rollbackIapeer(env: NodeJS.ProcessEnv = process.env): RollbackRe
267
290
  const tmp = `${binPath}.rollback.tmp`
268
291
  try {
269
292
  copyFileSync(prev, tmp)
293
+ // Same staged gate as install: never publish an invalid rollback target.
294
+ const signStable = deps.signStable ?? signInstalledBinary
295
+ const verifySignature = deps.verifySignature ?? ensureExecutableSignature
296
+ signStable(tmp, env)
297
+ verifySignature(tmp, { env })
270
298
  renameSync(tmp, binPath)
271
- // Keep the stable requirement on the restored bytes too (a .prev taken before
272
- // the signing era is ad-hoc — re-signing it heals that). Soft by design.
273
- signInstalledBinary(binPath, env)
274
299
  } catch (e) {
275
300
  try {
276
- if (existsSync(tmp)) renameSync(tmp, `${tmp}.discard`) // never leave a half-written tmp on the path
301
+ rmSync(tmp, { force: true })
277
302
  } catch {
278
303
  /* best-effort */
279
304
  }
@@ -0,0 +1,94 @@
1
+ // Lightweight package-facing activation gate. No keychain/TCC identity creation:
2
+ // preserve a valid signature; repair only a trusted, installer-owned staged file.
3
+ import { closeSync, fstatSync, openSync, readSync } from 'fs'
4
+ import { spawnSync } from 'child_process'
5
+
6
+ export interface SignatureRunner {
7
+ (command: string, args: string[], env: NodeJS.ProcessEnv): {
8
+ status: number | null
9
+ stdout?: string
10
+ stderr?: string
11
+ }
12
+ }
13
+
14
+ export interface ExecutableSignatureOptions {
15
+ env?: NodeJS.ProcessEnv
16
+ /** Default true for staged build artifacts. Use false for installed binaries. */
17
+ repair?: boolean
18
+ /** Test seams: injected runners may run under the sandbox; real codesign cannot. */
19
+ run?: SignatureRunner
20
+ platform?: NodeJS.Platform
21
+ }
22
+
23
+ export interface ExecutableSignatureOutcome {
24
+ state: 'verified' | 'repaired' | 'skipped-platform' | 'skipped-non-macho' | 'skipped-sandbox'
25
+ }
26
+
27
+ const MACH_O_MAGIC = new Set([
28
+ 0xfeedface, 0xcefaedfe, 0xfeedfacf, 0xcffaedfe, // thin, 32/64-bit, either endianness
29
+ 0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca, // universal, 32/64-bit
30
+ ])
31
+
32
+ function executableKind(path: string): 'macho' | 'script' | 'unknown' {
33
+ const fd = openSync(path, 'r') // missing/unreadable artifact is a hard failure
34
+ try {
35
+ const magic = Buffer.alloc(4)
36
+ const length = readSync(fd, magic, 0, 4, 0)
37
+ if (length === 4 && MACH_O_MAGIC.has(magic.readUInt32BE(0))) {
38
+ const value = magic.readUInt32BE(0)
39
+ const headerSize = value === 0xfeedface || value === 0xcefaedfe ? 28
40
+ : value === 0xfeedfacf || value === 0xcffaedfe ? 32 : 8
41
+ // codesign can sign a magic-only stub as generic data using xattrs. That
42
+ // success is not evidence of a Mach-O executable: require its full header.
43
+ return fstatSync(fd).size >= headerSize ? 'macho' : 'unknown'
44
+ }
45
+ if (length >= 2 && magic[0] === 0x23 && magic[1] === 0x21) return 'script'
46
+ return 'unknown'
47
+ } finally {
48
+ closeSync(fd)
49
+ }
50
+ }
51
+
52
+ const defaultRunner: SignatureRunner = (command, args, env) => {
53
+ const r = spawnSync(command, args, { encoding: 'utf8', env, timeout: 30_000 })
54
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? '', stderr: r.error?.message ?? r.stderr ?? '' }
55
+ }
56
+
57
+ /**
58
+ * macOS: strict verify → (if invalid and repair allowed) ad-hoc sign → strict verify.
59
+ * Throws on failure. Call AFTER compile/copy/chmod and BEFORE rename/manifest.
60
+ * Valid certificate-backed signatures stay byte-identical. Repair establishes
61
+ * signature validity, not provenance: never use it to trust arbitrary modified code.
62
+ */
63
+ export function ensureExecutableSignature(
64
+ path: string,
65
+ opts: ExecutableSignatureOptions = {},
66
+ ): ExecutableSignatureOutcome {
67
+ if ((opts.platform ?? process.platform) !== 'darwin') return { state: 'skipped-platform' }
68
+ const env = opts.env ?? process.env
69
+ // Both flags count, so a caller cannot accidentally re-enable real commands by
70
+ // passing a sparse env. DI tests use their own runner, never the production one.
71
+ if (!opts.run && (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1')) {
72
+ return { state: 'skipped-sandbox' }
73
+ }
74
+ const kind = executableKind(path)
75
+ if (kind === 'script') return { state: 'skipped-non-macho' }
76
+ if (kind === 'unknown') throw new Error(`executable signature verification failed for ${path}: not a Mach-O executable or shebang launcher`)
77
+ const run = opts.run ?? defaultRunner
78
+ const verify = (): ReturnType<SignatureRunner> => run('/usr/bin/codesign', ['--verify', '--deep', '--strict', path], env)
79
+ const fail = (step: string, result: ReturnType<SignatureRunner>): never => {
80
+ const detail = result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status}`
81
+ throw new Error(`executable signature ${step} failed for ${path}: ${detail}`)
82
+ }
83
+ const initial = verify()
84
+ if (initial.status === 0) return { state: 'verified' }
85
+ // A timeout/spawn error/signal is not evidence of an invalid signature. Do not
86
+ // rewrite code when the verification tool itself could not run to completion.
87
+ if (initial.status === null) fail('verification', initial)
88
+ if (opts.repair === false) fail('verification', initial)
89
+ const signed = run('/usr/bin/codesign', ['--force', '--sign', '-', path], env)
90
+ if (signed.status !== 0) fail('repair', signed)
91
+ const final = verify()
92
+ if (final.status !== 0) fail('verification after repair', final)
93
+ return { state: 'repaired' }
94
+ }
@@ -16,9 +16,9 @@
16
16
  // bytes. Trust of the cert chain is NOT needed: codesign signs with an untrusted
17
17
  // (CSSMERR_TP_NOT_TRUSTED) identity fine, and TCC matches the requirement.
18
18
  //
19
- // Failure policy: SOFT. The binary works ad-hoc-signed exactly as before; a
20
- // signing hiccup must never break install/update. It is reported loud (the
21
- // operator learns TCC prompts will re-appear) but the install succeeds.
19
+ // Stable-identity failure policy: SOFT. Executable signature validity is a
20
+ // separate HARD gate (signature.ts) before activation; invalid Bun linker
21
+ // signatures cannot be assumed runnable after a stable-signing failure.
22
22
 
23
23
  import { mkdtempSync, rmSync } from 'fs'
24
24
  import { tmpdir } from 'os'
@@ -63,7 +63,7 @@ export interface SigningOutcome {
63
63
  | 'signed' // re-signed with the existing identity
64
64
  | 'signed-new-identity' // identity created this run (the ONE install-time event), then signed
65
65
  | 'skipped-sandbox' // tests never touch the real keychain
66
- | 'failed-soft' // signing failed — binary stays ad-hoc (works; TCC prompts return)
66
+ | 'failed-soft' // stable signing failed; the activation gate still decides executability
67
67
  detail?: string
68
68
  }
69
69
 
@@ -113,7 +113,7 @@ function createIdentity(run: SigningRunner): { ok: boolean; detail?: string } {
113
113
 
114
114
  /**
115
115
  * Re-sign the installed binary with the stable local identity (creating the
116
- * identity on first use). Called by installIapeer after the atomic rename —
116
+ * identity on first use). Called by installIapeer BEFORE the atomic rename —
117
117
  * i.e. on EVERY install/update path, so the designated requirement (and with it
118
118
  * every TCC grant) stays constant while the bytes change.
119
119
  */
@@ -131,7 +131,7 @@ export function signInstalledBinary(
131
131
  if (!identityPresent(run)) {
132
132
  const c = createIdentity(run)
133
133
  if (!c.ok) {
134
- return { state: 'failed-soft', detail: `${c.detail} — binary stays ad-hoc-signed (works, but TCC prompts will re-appear after updates)` }
134
+ return { state: 'failed-soft', detail: `${c.detail} — stable TCC signing unavailable; executable signature must pass the activation gate (TCC prompts may re-appear after updates)` }
135
135
  }
136
136
  created = true
137
137
  }
@@ -139,7 +139,7 @@ export function signInstalledBinary(
139
139
  if (sign.status !== 0) {
140
140
  return {
141
141
  state: 'failed-soft',
142
- detail: `codesign failed: ${sign.stderr.trim().split('\n')[0] ?? `exit ${sign.status}`} — binary stays ad-hoc-signed (works, but TCC prompts will re-appear after updates)`,
142
+ detail: `codesign failed: ${sign.stderr.trim().split('\n')[0] ?? `exit ${sign.status}`} — stable TCC signing unavailable; executable signature must pass the activation gate (TCC prompts may re-appear after updates)`,
143
143
  }
144
144
  }
145
145
  return created ? { state: 'signed-new-identity' } : { state: 'signed' }
@@ -366,6 +366,13 @@ export const claudeAdapter: RuntimeAdapter = {
366
366
  if (pane.includes('Resume from summary') || pane.includes('Bypass Permissions mode')) {
367
367
  return /❯\s*2\./.test(pane) ? ['Enter'] : ['Down']
368
368
  }
369
+ // Folder-trust: older claude defaulted the cursor to "1. Yes, proceed", but 2.1.30x
370
+ // renders "❯ No, exit" / "Yes, I trust this folder" with the DEFAULT on exit — a bare
371
+ // Enter cleanly exits the peer (live: claude-boris child-exit 5s after first wake,
372
+ // 2026-10-17). Same discipline as Bypass: Enter only once the cursor is SEEN on Yes.
373
+ if (pane.includes('trust this folder') && /No,\s*exit/.test(pane)) {
374
+ return /❯\s*(?:\d\.\s*)?Yes\b/.test(pane) ? ['Enter'] : ['Down']
375
+ }
369
376
  if (
370
377
  pane.includes('trust this folder') ||
371
378
  pane.includes('Allow external CLAUDE.md file imports?') ||
@@ -162,7 +162,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
162
162
  interrupt = null
163
163
  }
164
164
  // Signal-initiated stop (bootout / shutdown / kickstart -k) tears the session down WITH this watcher —
165
- // kill the supervisor daemon (killPtyHost: SIGTERM → it SIGKILLs the child + cleans up sock/pid/serve)
165
+ // kill the supervisor daemon (SIGTERM → hard teardown of its PTY-owned tree + sock/pid/serve cleanup)
166
166
  // so the detached child does not outlive bootout holding stale in-memory state. A NATURAL death
167
167
  // (stop=false) skips this: nothing to tear down, exit 0 → KeepAlive respawns a fresh bring-up.
168
168
  if (stop) killPtyHost(identity, env)
@@ -45,7 +45,7 @@ export function hostSessionAlive(identity: string, env: NodeJS.ProcessEnv = proc
45
45
  return hostDaemonAlive(hostRunDir(env), identity)
46
46
  }
47
47
 
48
- /** Reap a hosted session: SIGTERM the supervisor daemon (its shutdown SIGKILLs the child + cleans up).
48
+ /** Reap a hosted session: SIGTERM the supervisor daemon (hard-kills its PTY-owned tree + cleans up).
49
49
  * env sandboxes the run-dir: a test with an injected root must NEVER SIGTERM a real fleet peer. */
50
50
  export function killPtyHost(identity: string, env: NodeJS.ProcessEnv = process.env): void {
51
51
  killSupervisorSession(hostRunDir(env), identity)
@@ -1257,7 +1257,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1257
1257
  // ─────────────────────────────────────────────────────────────────────────────
1258
1258
 
1259
1259
  export function killSession(_sock: string, identity: string, env: NodeJS.ProcessEnv = process.env): void {
1260
- // pty-only: SIGTERM the supervisor daemon — its shutdown SIGKILLs the child and removes the
1260
+ // pty-only: SIGTERM the supervisor daemon — its shutdown kills the PTY-owned tree and removes the
1261
1261
  // sock/pid/serve-spec/markers. Idempotent if the session is already gone. (`_sock` retained for
1262
1262
  // call-site compatibility; no tmux sockets exist.) env sandboxes the run-dir — a test with an
1263
1263
  // injected root must NEVER SIGTERM a real fleet peer's supervisor.