@agfpd/iapeer 0.2.19 → 0.2.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -153,6 +153,37 @@ describe('send validation', () => {
153
153
  })
154
154
  })
155
155
 
156
+ describe('send → ephemeral target: M3 FIFO parity with the daemon path (iapeer-memory ask)', () => {
157
+ test('a CLI send to a wake_policy:ephemeral peer ENQUEUES (queued ack), no live/miss bypass', async () => {
158
+ // an ephemeral worker cwd (profile declares wake_policy) registered in the index
159
+ const cwd = mkdtempSync(join(tmpdir(), 'iapeer-cli-eph-'))
160
+ mkdirSync(join(cwd, '.iapeer'), { recursive: true })
161
+ writeFileSync(
162
+ join(cwd, '.iapeer', 'peer-profile.json'),
163
+ JSON.stringify({ personality: 'ephw', runtime: 'claude', runtimes: ['claude'], intelligence: 'artificial', wake_policy: 'ephemeral' }),
164
+ )
165
+ const e = env()
166
+ // routeSend resolves the peers index from the PROCESS env (transport reads
167
+ // readPeersIndex() bare) — point the process-level root at the sandbox too.
168
+ const prevRoot = process.env.IAPEER_ROOT
169
+ process.env.IAPEER_ROOT = root
170
+ try {
171
+ await upsertPeer({ personality: 'ephw', runtime: 'claude', cwd, intelligence: 'artificial' }, { rootDir: root })
172
+ await register('sender')
173
+ const r = await sendMessage({ from: 'claude-sender', target: 'ephw', message: 'task', env: e })
174
+ expect(r.queued).toBe(true) // serialized via the disk FIFO, exactly like the daemon path
175
+ expect(r.queueDepth).toBe(1)
176
+ // the task is durably on disk for the daemon tick to drain
177
+ const qdir = join(loadLifecycleConfig(e).stateDir, 'claude-ephw.queue')
178
+ expect(existsSync(qdir)).toBe(true)
179
+ } finally {
180
+ if (prevRoot !== undefined) process.env.IAPEER_ROOT = prevRoot
181
+ else delete process.env.IAPEER_ROOT
182
+ rmSync(cwd, { recursive: true, force: true })
183
+ }
184
+ })
185
+ })
186
+
156
187
  describe('--help/-h global intercept (CLI hygiene — usage printed, NOTHING executed)', () => {
157
188
  let captured: string
158
189
  let origWrite: typeof process.stdout.write
package/src/cli/index.ts CHANGED
@@ -323,9 +323,21 @@ export interface SendOptions extends CliEnvOptions {
323
323
  const cliWake: WakeFn = req =>
324
324
  wakeOrSpawn({ personality: req.personality, runtime: req.runtime, topic: req.topic, task: req.task })
325
325
 
326
- export async function sendMessage(opts: SendOptions): Promise<{ ok: true; delivered_to: { personality: string; runtime: string } }> {
326
+ export async function sendMessage(
327
+ opts: SendOptions,
328
+ ): Promise<{ ok: true; delivered_to: { personality: string; runtime: string }; queued?: boolean; queueDepth?: number }> {
327
329
  const env = opts.env ?? process.env
328
330
  const caller = resolveCallerIdentity(parseIdentity(opts.from), readPeersIndex({ env }))
331
+ // wake_policy:ephemeral M3 parity (iapeer-memory ask, 10.06): the CLI path used
332
+ // to route an ephemeral target through the normal live/miss path — a notifier
333
+ // burst landed as TURNS in one live worker session instead of serializing
334
+ // through the disk FIFO the daemon path uses. Same seam, ONE difference: the
335
+ // drain kick is a NOOP here — a CLI process exits right after the ack, so an
336
+ // unawaited in-process wake would die with it; the daemon's supervise-tick
337
+ // drain scan (≤60 s) picks the queue up — the EXISTING retry path for failed
338
+ // kicks, not a new mechanism.
339
+ const { makeEphemeralRouteDeps } = await import('../daemon/main.ts')
340
+ const cfg = loadLifecycleConfig(env)
329
341
  const result = await routeSend(
330
342
  caller,
331
343
  {
@@ -335,10 +347,15 @@ export async function sendMessage(opts: SendOptions): Promise<{ ok: true; delive
335
347
  topic: opts.topic,
336
348
  attachments: opts.attachments,
337
349
  },
338
- { wake: cliWake },
350
+ { wake: cliWake, ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}) },
339
351
  )
340
352
  if (!result.ok) throw new Error(result.error.message)
341
- return { ok: true, delivered_to: result.value.delivered_to }
353
+ return {
354
+ ok: true,
355
+ delivered_to: result.value.delivered_to,
356
+ queued: result.value.queued,
357
+ queueDepth: result.value.queueDepth,
358
+ }
342
359
  }
343
360
 
344
361
  function parseIdentity(identity: string): { personality: string; runtime: Runtime } {
@@ -714,7 +731,11 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
714
731
  attachments: attachments.length ? attachments : undefined,
715
732
  env,
716
733
  })
717
- out(`delivered to ${r.delivered_to.personality} (${r.delivered_to.runtime})\n`)
734
+ out(
735
+ r.queued
736
+ ? `queued for ${r.delivered_to.personality} (${r.delivered_to.runtime}), depth ${r.queueDepth ?? '?'} — the daemon tick drains it\n`
737
+ : `delivered to ${r.delivered_to.personality} (${r.delivered_to.runtime})\n`,
738
+ )
718
739
  return 0
719
740
  }
720
741
  case 'version':
@@ -807,10 +828,17 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
807
828
  ensureGlobalIapScaffold({ env })
808
829
  const r = installIapeer(fileURLToPath(import.meta.url), env)
809
830
  const plist = installDaemonPlist({ env })
831
+ const signingLine =
832
+ r.signing == null
833
+ ? ''
834
+ : r.signing.state === 'failed-soft'
835
+ ? ` WARNING signing: ${r.signing.detail}\n`
836
+ : ` signing: ${r.signing.state}${r.signing.state === 'signed-new-identity' ? ' (local identity created — the one install-time event)' : ''}\n`
810
837
  out(
811
838
  `installed iapeer → ${r.binPath}` +
812
839
  `${r.prevPath ? ` (previous kept: ${r.prevPath})` : ''}` +
813
840
  `${r.size ? ` (${Math.round(r.size / 1e6)}M)` : ''}\n` +
841
+ signingLine +
814
842
  ` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
815
843
  ` daemon plist written: ${plist}\n` +
816
844
  ` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
@@ -5,13 +5,19 @@
5
5
  // plists run the INSTALLED binary, and any edit/git-op in the tree no longer hits
6
6
  // prod. Update = atomic overwrite in place (build to .tmp → rename over), with ONE
7
7
  // .prev for rollback. NO versions/ catalog + resolver-symlink (that pattern is for
8
- // multi-version toolchains; the foundation is one-latest — and a stable path keeps
9
- // macOS TCC rights through updates, which a versioned path would re-prompt).
8
+ // multi-version toolchains; the foundation is one-latest).
9
+ //
10
+ // macOS TCC: a stable PATH is NOT enough to keep grants through updates — TCC keys
11
+ // on the code requirement, and an ad-hoc bun-compiled binary's requirement is its
12
+ // CDHash (changes every build → re-prompts; live-proven 10.06, Артур's DX
13
+ // requirement). signInstalledBinary (signing.ts) re-signs each install with the
14
+ // stable local identity so the designated requirement — and the grants — survive.
10
15
 
11
16
  import { copyFileSync, existsSync, mkdirSync, renameSync, statSync } from 'fs'
12
17
  import { homedir } from 'os'
13
18
  import { join } from 'path'
14
19
  import { spawnSync } from 'child_process'
20
+ import { signInstalledBinary, type SigningOutcome } from './signing.ts'
15
21
 
16
22
  /** The stable host-wide install path of the `iapeer` binary. Standard user-bin (no
17
23
  * admin, not tied to a node/bun version), ON $PATH. The launchd plists reference
@@ -30,6 +36,9 @@ export interface InstallResult {
30
36
  prevPath?: string
31
37
  /** Bytes of the installed binary. */
32
38
  size?: number
39
+ /** Stable-identity re-sign outcome (TCC grants survive updates). Soft: a signing
40
+ * hiccup never fails the install — the binary works ad-hoc-signed. */
41
+ signing?: SigningOutcome
33
42
  }
34
43
 
35
44
  /**
@@ -74,13 +83,16 @@ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = pr
74
83
  copyFileSync(binPath, prevPath)
75
84
  }
76
85
  renameSync(tmp, binPath) // atomic replace in place (POSIX rename over an existing file)
86
+ // Stable-identity re-sign (TCC grants survive updates). AFTER the rename: the
87
+ // signature belongs to the final inode at the final path. Soft-fail by design.
88
+ const signing = signInstalledBinary(binPath, env)
77
89
  let size: number | undefined
78
90
  try {
79
91
  size = statSync(binPath).size
80
92
  } catch {
81
93
  /* best-effort */
82
94
  }
83
- return { binPath, prevPath, size }
95
+ return { binPath, prevPath, size, signing }
84
96
  }
85
97
 
86
98
  /** The previous-binary path kept by the last install for one-step rollback. */
@@ -113,6 +125,9 @@ export function rollbackIapeer(env: NodeJS.ProcessEnv = process.env): RollbackRe
113
125
  try {
114
126
  copyFileSync(prev, tmp)
115
127
  renameSync(tmp, binPath)
128
+ // Keep the stable requirement on the restored bytes too (a .prev taken before
129
+ // the signing era is ad-hoc — re-signing it heals that). Soft by design.
130
+ signInstalledBinary(binPath, env)
116
131
  } catch (e) {
117
132
  try {
118
133
  if (existsSync(tmp)) renameSync(tmp, `${tmp}.discard`) // never leave a half-written tmp on the path
@@ -0,0 +1,84 @@
1
+ // signInstalledBinary — stable-identity re-sign so TCC grants survive updates
2
+ // (Артур's DX requirement 10.06). DI-runner units; the real keychain flow was
3
+ // proven live (/tmp experiment: two binaries, different CDHash, IDENTICAL
4
+ // designated requirement `identifier "com.agfpd.iapeer" and certificate leaf`).
5
+ // The sandbox guard double-checks process.env, so these tests inject a runner
6
+ // AND call with the flag stripped via a direct env — guard tested separately.
7
+
8
+ import { describe, expect, test } from 'bun:test'
9
+ import { SIGNING_IDENTIFIER, SIGNING_IDENTITY_CN, signInstalledBinary, type SigningRunner } from './signing.ts'
10
+
11
+ function harness(opts: { identityExists: boolean; failAt?: 'req' | 'pkcs12' | 'import' | 'codesign' }) {
12
+ const calls: { cmd: string; args: string[] }[] = []
13
+ const run: SigningRunner = (cmd, args) => {
14
+ calls.push({ cmd, args })
15
+ if (cmd === 'security' && args[0] === 'find-identity') {
16
+ return { status: 0, stdout: opts.identityExists ? `1) ABC "${SIGNING_IDENTITY_CN}" (CSSMERR_TP_NOT_TRUSTED)\n` : 'no identities\n', stderr: '' }
17
+ }
18
+ if (cmd.endsWith('openssl') && args[0] === 'req') return { status: opts.failAt === 'req' ? 1 : 0, stdout: '', stderr: 'req boom' }
19
+ if (cmd.endsWith('openssl') && args[0] === 'pkcs12') return { status: opts.failAt === 'pkcs12' ? 1 : 0, stdout: '', stderr: 'p12 boom' }
20
+ if (cmd === 'security' && args[0] === 'import') return { status: opts.failAt === 'import' ? 1 : 0, stdout: '', stderr: 'import boom' }
21
+ if (cmd === 'codesign') return { status: opts.failAt === 'codesign' ? 1 : 0, stdout: '', stderr: 'sign boom' }
22
+ return { status: 0, stdout: '', stderr: '' }
23
+ }
24
+ return { calls, run }
25
+ }
26
+
27
+ // NOTE: process.env.IAPEER_TEST_SANDBOX === '1' under `bun run test`, so the
28
+ // guard SHORT-CIRCUITS every real call — these units therefore stub process.env
29
+ // off for the duration of each call.
30
+ function withSandboxOff<T>(fn: () => T): T {
31
+ const prev = process.env.IAPEER_TEST_SANDBOX
32
+ delete process.env.IAPEER_TEST_SANDBOX
33
+ try {
34
+ return fn()
35
+ } finally {
36
+ if (prev !== undefined) process.env.IAPEER_TEST_SANDBOX = prev
37
+ }
38
+ }
39
+
40
+ describe('signInstalledBinary (stable identity → TCC grants survive updates)', () => {
41
+ test('sandbox guard: never touches the keychain under IAPEER_TEST_SANDBOX', () => {
42
+ const h = harness({ identityExists: true })
43
+ const r = signInstalledBinary('/x/iapeer', { IAPEER_TEST_SANDBOX: '1' } as NodeJS.ProcessEnv, h.run)
44
+ expect(r.state).toBe('skipped-sandbox')
45
+ expect(h.calls.length).toBe(0)
46
+ })
47
+
48
+ test('existing identity → single codesign with the stable identifier', () => {
49
+ const h = harness({ identityExists: true })
50
+ const r = withSandboxOff(() => signInstalledBinary('/x/iapeer', {} as NodeJS.ProcessEnv, h.run))
51
+ expect(r.state).toBe('signed')
52
+ const sign = h.calls.find(c => c.cmd === 'codesign')!
53
+ expect(sign.args).toEqual(['-f', '-s', SIGNING_IDENTITY_CN, '--identifier', SIGNING_IDENTIFIER, '/x/iapeer'])
54
+ // identity lookup is NOT -v (an untrusted self-signed identity must be found)
55
+ const find = h.calls.find(c => c.args[0] === 'find-identity')!
56
+ expect(find.args).not.toContain('-v')
57
+ })
58
+
59
+ test('no identity → created once (openssl req → pkcs12 → import -T codesign), then signed', () => {
60
+ const h = harness({ identityExists: false })
61
+ const r = withSandboxOff(() => signInstalledBinary('/x/iapeer', {} as NodeJS.ProcessEnv, h.run))
62
+ expect(r.state).toBe('signed-new-identity')
63
+ const seq = h.calls.map(c => `${c.cmd.split('/').pop()}:${c.args[0]}`)
64
+ expect(seq).toEqual(['security:find-identity', 'openssl:req', 'openssl:pkcs12', 'security:import', 'codesign:-f'])
65
+ const imp = h.calls.find(c => c.args[0] === 'import')!
66
+ expect(imp.args).toContain('-T') // codesign pre-authorized in the key ACL
67
+ expect(imp.args).toContain('/usr/bin/codesign')
68
+ })
69
+
70
+ test('identity-creation failure → failed-soft with the loud TCC consequence, codesign never attempted', () => {
71
+ const h = harness({ identityExists: false, failAt: 'import' })
72
+ const r = withSandboxOff(() => signInstalledBinary('/x/iapeer', {} as NodeJS.ProcessEnv, h.run))
73
+ expect(r.state).toBe('failed-soft')
74
+ expect(r.detail).toContain('TCC prompts will re-appear')
75
+ expect(h.calls.some(c => c.cmd === 'codesign')).toBe(false)
76
+ })
77
+
78
+ test('codesign failure → failed-soft (install never breaks on a signing hiccup)', () => {
79
+ const h = harness({ identityExists: true, failAt: 'codesign' })
80
+ const r = withSandboxOff(() => signInstalledBinary('/x/iapeer', {} as NodeJS.ProcessEnv, h.run))
81
+ expect(r.state).toBe('failed-soft')
82
+ expect(r.detail).toContain('sign boom')
83
+ })
84
+ })
@@ -0,0 +1,147 @@
1
+ // Stable code-signing for the installed binary — TCC grants must SURVIVE updates
2
+ // (Артур's DX requirement 10.06: «1 раз при установке, потом обновления не должны
3
+ // опять триггерить»).
4
+ //
5
+ // ROOT CAUSE (proven on the host): `bun build --compile` output is ad-hoc
6
+ // linker-signed (Identifier=a.out, no cert chain) — its only stable identity is
7
+ // the CDHash, which CHANGES with every build. macOS TCC keys grants on the code
8
+ // requirement; for an ad-hoc binary that collapses to the cdhash → every update
9
+ // is a NEW TCC subject → re-prompts.
10
+ //
11
+ // FIX (proven live, /tmp experiment 10.06): a LOCAL self-signed code-signing
12
+ // identity ("iapeer Local Codesign", created once at install) re-signs the binary
13
+ // after every build. Two different binaries signed by it carry the IDENTICAL
14
+ // designated requirement:
15
+ // identifier "com.agfpd.iapeer" and certificate leaf = H"<leaf-hash>"
16
+ // — stable across updates, so the TCC grant follows the requirement, not the
17
+ // bytes. Trust of the cert chain is NOT needed: codesign signs with an untrusted
18
+ // (CSSMERR_TP_NOT_TRUSTED) identity fine, and TCC matches the requirement.
19
+ //
20
+ // Failure policy: SOFT. The binary works ad-hoc-signed exactly as before; a
21
+ // signing hiccup must never break install/update. It is reported loud (the
22
+ // operator learns TCC prompts will re-appear) but the install succeeds.
23
+
24
+ import { mkdtempSync, rmSync } from 'fs'
25
+ import { tmpdir } from 'os'
26
+ import { join } from 'path'
27
+ import { spawnSync } from 'child_process'
28
+
29
+ /** CROSS-PRODUCT CONTRACT (agreed with iapeer-memory, 10.06): this CN is the
30
+ * SHARED signing identity of the whole agfpd stack. Each product signs with its
31
+ * OWN --identifier (foundation: com.agfpd.iapeer; memory: com.agfpd.iapeer-memory),
32
+ * so TCC subjects stay separate while the host carries ONE key (one keychain
33
+ * prompt ever). Creation is first-needs-creates with the IDENTICAL profile (EKU
34
+ * codeSigning, system LibreSSL p12, import -T /usr/bin/codesign) on both sides.
35
+ * Changing the CN or the creation profile is a COORDINATED change across repos.
36
+ * Known shared costs: re-creating the identity (deleted/expired — cert is 10 y)
37
+ * migrates the TCC grants of EVERY stack product at once; a concurrent
38
+ * first-creation by two installers could duplicate the CN (codesign would then
39
+ * report an ambiguous identity) — installs are operator-sequential, residual
40
+ * risk accepted. */
41
+ export const SIGNING_IDENTITY_CN = 'iapeer Local Codesign'
42
+ export const SIGNING_IDENTIFIER = 'com.agfpd.iapeer'
43
+
44
+ /** System LibreSSL — ALWAYS present on macOS and its pkcs12 output imports into
45
+ * the keychain directly. (Homebrew OpenSSL 3.x defaults to PBES2/AES p12, which
46
+ * `security import` rejects with "MAC verification failed" unless -legacy —
47
+ * live-caught during the experiment; pinning the system binary removes the
48
+ * PATH-dependent branch entirely.) */
49
+ const SYSTEM_OPENSSL = '/usr/bin/openssl'
50
+
51
+ export interface SigningRunner {
52
+ (cmd: string, args: string[], input?: string): { status: number | null; stdout: string; stderr: string }
53
+ }
54
+
55
+ const defaultRunner: SigningRunner = (cmd, args) => {
56
+ // 90 s ceiling: a keychain GUI prompt left unanswered must not wedge an
57
+ // unattended update forever — it degrades to failed-soft instead.
58
+ const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 90_000 })
59
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
60
+ }
61
+
62
+ export interface SigningOutcome {
63
+ state:
64
+ | 'signed' // re-signed with the existing identity
65
+ | 'signed-new-identity' // identity created this run (the ONE install-time event), then signed
66
+ | 'skipped-sandbox' // tests never touch the real keychain
67
+ | 'failed-soft' // signing failed — binary stays ad-hoc (works; TCC prompts return)
68
+ detail?: string
69
+ }
70
+
71
+ /** True iff the local signing identity already exists in the keychain. Deliberately
72
+ * NOT `-v` (valid-only): the self-signed cert reads CSSMERR_TP_NOT_TRUSTED, which
73
+ * is fine for signing — `-v` would hide it and re-create endlessly. */
74
+ function identityPresent(run: SigningRunner): boolean {
75
+ const r = run('security', ['find-identity', '-p', 'codesigning'])
76
+ return r.status === 0 && r.stdout.includes(`"${SIGNING_IDENTITY_CN}"`)
77
+ }
78
+
79
+ /** Create the local self-signed code-signing identity (key + cert with EKU
80
+ * codeSigning → p12 → keychain import with codesign pre-authorized via -T).
81
+ * The one-time install event. */
82
+ function createIdentity(run: SigningRunner): { ok: boolean; detail?: string } {
83
+ const dir = mkdtempSync(join(tmpdir(), 'iapeer-signing-'))
84
+ const key = join(dir, 'key.pem')
85
+ const cert = join(dir, 'cert.pem')
86
+ const p12 = join(dir, 'id.p12')
87
+ // Throwaway p12 transport password — the file lives seconds inside a 0700 tmp dir.
88
+ const pass = `iapeer-${process.pid}-${Math.floor(Math.random() * 1e9)}`
89
+ try {
90
+ const req = run(SYSTEM_OPENSSL, [
91
+ 'req', '-x509', '-newkey', 'rsa:2048', '-keyout', key, '-out', cert,
92
+ '-days', '3650', '-nodes', '-subj', `/CN=${SIGNING_IDENTITY_CN}`,
93
+ '-addext', 'keyUsage=digitalSignature', '-addext', 'extendedKeyUsage=codeSigning',
94
+ ])
95
+ if (req.status !== 0) return { ok: false, detail: `openssl req failed: ${req.stderr.trim().split('\n')[0] ?? ''}` }
96
+ const exp = run(SYSTEM_OPENSSL, [
97
+ 'pkcs12', '-export', '-inkey', key, '-in', cert, '-out', p12,
98
+ '-passout', `pass:${pass}`, '-name', SIGNING_IDENTITY_CN,
99
+ ])
100
+ if (exp.status !== 0) return { ok: false, detail: `openssl pkcs12 failed: ${exp.stderr.trim().split('\n')[0] ?? ''}` }
101
+ // -T /usr/bin/codesign pre-authorizes codesign in the key's ACL — at most ONE
102
+ // keychain confirmation at the very first signing (the install-time event).
103
+ const imp = run('security', ['import', p12, '-P', pass, '-T', '/usr/bin/codesign'])
104
+ if (imp.status !== 0) return { ok: false, detail: `security import failed: ${imp.stderr.trim().split('\n')[0] ?? ''}` }
105
+ return { ok: true }
106
+ } finally {
107
+ try {
108
+ rmSync(dir, { recursive: true, force: true })
109
+ } catch {
110
+ /* best-effort cleanup of the throwaway key material */
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Re-sign the installed binary with the stable local identity (creating the
117
+ * identity on first use). Called by installIapeer after the atomic rename —
118
+ * i.e. on EVERY install/update path, so the designated requirement (and with it
119
+ * every TCC grant) stays constant while the bytes change.
120
+ */
121
+ export function signInstalledBinary(
122
+ binPath: string,
123
+ env: NodeJS.ProcessEnv = process.env,
124
+ run: SigningRunner = defaultRunner,
125
+ ): SigningOutcome {
126
+ // Keychain + codesign are HOST-GLOBAL — same fail-closed double-check as the
127
+ // launchctl guards: consult both the passed env and the process env.
128
+ if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') {
129
+ return { state: 'skipped-sandbox', detail: 'IAPEER_TEST_SANDBOX=1 — not touching the real keychain' }
130
+ }
131
+ let created = false
132
+ if (!identityPresent(run)) {
133
+ const c = createIdentity(run)
134
+ if (!c.ok) {
135
+ return { state: 'failed-soft', detail: `${c.detail} — binary stays ad-hoc-signed (works, but TCC prompts will re-appear after updates)` }
136
+ }
137
+ created = true
138
+ }
139
+ const sign = run('codesign', ['-f', '-s', SIGNING_IDENTITY_CN, '--identifier', SIGNING_IDENTIFIER, binPath])
140
+ if (sign.status !== 0) {
141
+ return {
142
+ state: 'failed-soft',
143
+ 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)`,
144
+ }
145
+ }
146
+ return created ? { state: 'signed-new-identity' } : { state: 'signed' }
147
+ }
@@ -65,11 +65,15 @@ function isExecutable(binOrName: string, env: NodeJS.ProcessEnv = process.env):
65
65
  }
66
66
  }
67
67
  // bare name → PRESENCE probe over PATH (`command -v` semantics), NO spawn.
68
- // History (both live finds 10.06): the original `--version` ANSWER probe HANGS
69
- // FOREVER for codex in a non-tty (three stray probes sat 25+ min); the 10 s
70
- // timeout that replaced it then DEGRADED a LIVE codex to 'runtime-missing'
71
- // masking a working runtime (boris's catch). The skip-decision only asks "is
72
- // the runtime installed", and presence answers that without executing anything.
68
+ // History (live finds 10.06): the original `--version` ANSWER probe hung forever
69
+ // (three stray probes sat 25+ min); the 10 s timeout that replaced it then
70
+ // DEGRADED a live-looking codex to 'runtime-missing' (boris's catch). ROOT CAUSE
71
+ // (final, boris+iapeer-memory): macOS held the cask-updated codex on a GUI
72
+ // launch-approval dialog EVERY invocation parked before main (observed as a
73
+ // dyld hang) until the owner confirmed the dialog. NOT a non-tty class, not a
74
+ // broken binary. The presence probe stays right regardless: the skip question is
75
+ // "is the runtime installed", and presence answers it without executing a
76
+ // possibly-wedged binary at all.
73
77
  for (const dir of (env.PATH ?? '').split(':')) {
74
78
  if (!dir) continue
75
79
  try {
@@ -90,10 +94,11 @@ function isExecutable(binOrName: string, env: NodeJS.ProcessEnv = process.env):
90
94
  */
91
95
  export function isMarketplaceRegistered(runtime: OnboardRuntime, env: NodeJS.ProcessEnv = process.env): boolean {
92
96
  const bin = runtimeBin(runtime, env)
93
- // HARD TIMEOUT — the codex CLI hangs FOREVER in a non-tty on ANY subcommand
94
- // (live 10.06: first `--version`, then `plugin marketplace list` after the
95
- // presence-probe fix let a live codex through). Timeout status null →
96
- // "not registered" the add (also time-bounded) decides; never a wedge.
97
+ // HARD TIMEOUT — a runtime CLI can wedge before main on ANY invocation (live
98
+ // 10.06: macOS launch-approval pending after a cask update parked codex — first
99
+ // `--version`, then this very `plugin marketplace list` after the presence
100
+ // probe let the binary through). Timeout status null → "not registered" →
101
+ // the add (also time-bounded) decides; never a wedge.
97
102
  const r = spawnSync(bin, ['plugin', 'marketplace', 'list'], { encoding: 'utf8', timeout: 60_000 })
98
103
  if (r.status !== 0) return false
99
104
  return isAgfpdInList(`${r.stdout ?? ''}`)
@@ -115,12 +120,13 @@ export function isAgfpdInList(listOutput: string): boolean {
115
120
  /** Register OUR marketplace for this runtime (`<runtime> plugin marketplace add <ref>`). */
116
121
  function registerMarketplace(runtime: OnboardRuntime, env: NodeJS.ProcessEnv): { ok: boolean; detail?: string } {
117
122
  const bin = runtimeBin(runtime, env)
118
- // Same hard timeout as the list probe (codex non-tty hang class)a wedged add
119
- // degrades to a loud 'failed' line instead of freezing the host phase.
123
+ // Same hard timeout as the list probe (the pre-main wedge class — known live
124
+ // representative: macOS launch-approval pending after a cask update) a wedged
125
+ // add degrades to a loud 'failed' line instead of freezing the host phase.
120
126
  const r = spawnSync(bin, ['plugin', 'marketplace', 'add', MARKETPLACE_REF], { encoding: 'utf8', timeout: 120_000 })
121
127
  return r.status === 0
122
128
  ? { ok: true }
123
- : { ok: false, detail: (r.stderr ?? '').trim() || (r.status === null ? 'timed out (non-tty hang?)' : `exit ${r.status}`) }
129
+ : { ok: false, detail: (r.stderr ?? '').trim() || (r.status === null ? 'timed out (wedged runtime CLI?)' : `exit ${r.status}`) }
124
130
  }
125
131
 
126
132
  /**