@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
|
@@ -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 {
|
|
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.
|
|
1920
|
-
//
|
|
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)) {
|
package/src/daemon/fleet.ts
CHANGED
|
@@ -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
|
)
|
package/src/daemon/index.ts
CHANGED
|
@@ -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
|
|
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
|
},
|
package/src/install/index.ts
CHANGED
|
@@ -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
|
|
46
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|
package/src/install/signing.ts
CHANGED
|
@@ -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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
|
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
|
|
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} —
|
|
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}`} —
|
|
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?') ||
|
package/src/launch/launchdRun.ts
CHANGED
|
@@ -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 (
|
|
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)
|
package/src/launch/ptyHost.ts
CHANGED
|
@@ -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
|
|
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)
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -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
|
|
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.
|