@agfpd/iapeer 0.2.19 → 0.2.20
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 +1 -1
- package/src/cli/index.ts +7 -0
- package/src/install/index.ts +18 -3
- package/src/install/signing.test.ts +84 -0
- package/src/install/signing.ts +135 -0
- package/src/onboard/index.ts +18 -12
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -807,10 +807,17 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
807
807
|
ensureGlobalIapScaffold({ env })
|
|
808
808
|
const r = installIapeer(fileURLToPath(import.meta.url), env)
|
|
809
809
|
const plist = installDaemonPlist({ env })
|
|
810
|
+
const signingLine =
|
|
811
|
+
r.signing == null
|
|
812
|
+
? ''
|
|
813
|
+
: r.signing.state === 'failed-soft'
|
|
814
|
+
? ` WARNING signing: ${r.signing.detail}\n`
|
|
815
|
+
: ` signing: ${r.signing.state}${r.signing.state === 'signed-new-identity' ? ' (local identity created — the one install-time event)' : ''}\n`
|
|
810
816
|
out(
|
|
811
817
|
`installed iapeer → ${r.binPath}` +
|
|
812
818
|
`${r.prevPath ? ` (previous kept: ${r.prevPath})` : ''}` +
|
|
813
819
|
`${r.size ? ` (${Math.round(r.size / 1e6)}M)` : ''}\n` +
|
|
820
|
+
signingLine +
|
|
814
821
|
` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
|
|
815
822
|
` daemon plist written: ${plist}\n` +
|
|
816
823
|
` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
|
package/src/install/index.ts
CHANGED
|
@@ -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
|
|
9
|
-
//
|
|
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,135 @@
|
|
|
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
|
+
export const SIGNING_IDENTITY_CN = 'iapeer Local Codesign'
|
|
30
|
+
export const SIGNING_IDENTIFIER = 'com.agfpd.iapeer'
|
|
31
|
+
|
|
32
|
+
/** System LibreSSL — ALWAYS present on macOS and its pkcs12 output imports into
|
|
33
|
+
* the keychain directly. (Homebrew OpenSSL 3.x defaults to PBES2/AES p12, which
|
|
34
|
+
* `security import` rejects with "MAC verification failed" unless -legacy —
|
|
35
|
+
* live-caught during the experiment; pinning the system binary removes the
|
|
36
|
+
* PATH-dependent branch entirely.) */
|
|
37
|
+
const SYSTEM_OPENSSL = '/usr/bin/openssl'
|
|
38
|
+
|
|
39
|
+
export interface SigningRunner {
|
|
40
|
+
(cmd: string, args: string[], input?: string): { status: number | null; stdout: string; stderr: string }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const defaultRunner: SigningRunner = (cmd, args) => {
|
|
44
|
+
// 90 s ceiling: a keychain GUI prompt left unanswered must not wedge an
|
|
45
|
+
// unattended update forever — it degrades to failed-soft instead.
|
|
46
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 90_000 })
|
|
47
|
+
return { status: r.error ? null : r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SigningOutcome {
|
|
51
|
+
state:
|
|
52
|
+
| 'signed' // re-signed with the existing identity
|
|
53
|
+
| 'signed-new-identity' // identity created this run (the ONE install-time event), then signed
|
|
54
|
+
| 'skipped-sandbox' // tests never touch the real keychain
|
|
55
|
+
| 'failed-soft' // signing failed — binary stays ad-hoc (works; TCC prompts return)
|
|
56
|
+
detail?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True iff the local signing identity already exists in the keychain. Deliberately
|
|
60
|
+
* NOT `-v` (valid-only): the self-signed cert reads CSSMERR_TP_NOT_TRUSTED, which
|
|
61
|
+
* is fine for signing — `-v` would hide it and re-create endlessly. */
|
|
62
|
+
function identityPresent(run: SigningRunner): boolean {
|
|
63
|
+
const r = run('security', ['find-identity', '-p', 'codesigning'])
|
|
64
|
+
return r.status === 0 && r.stdout.includes(`"${SIGNING_IDENTITY_CN}"`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Create the local self-signed code-signing identity (key + cert with EKU
|
|
68
|
+
* codeSigning → p12 → keychain import with codesign pre-authorized via -T).
|
|
69
|
+
* The one-time install event. */
|
|
70
|
+
function createIdentity(run: SigningRunner): { ok: boolean; detail?: string } {
|
|
71
|
+
const dir = mkdtempSync(join(tmpdir(), 'iapeer-signing-'))
|
|
72
|
+
const key = join(dir, 'key.pem')
|
|
73
|
+
const cert = join(dir, 'cert.pem')
|
|
74
|
+
const p12 = join(dir, 'id.p12')
|
|
75
|
+
// Throwaway p12 transport password — the file lives seconds inside a 0700 tmp dir.
|
|
76
|
+
const pass = `iapeer-${process.pid}-${Math.floor(Math.random() * 1e9)}`
|
|
77
|
+
try {
|
|
78
|
+
const req = run(SYSTEM_OPENSSL, [
|
|
79
|
+
'req', '-x509', '-newkey', 'rsa:2048', '-keyout', key, '-out', cert,
|
|
80
|
+
'-days', '3650', '-nodes', '-subj', `/CN=${SIGNING_IDENTITY_CN}`,
|
|
81
|
+
'-addext', 'keyUsage=digitalSignature', '-addext', 'extendedKeyUsage=codeSigning',
|
|
82
|
+
])
|
|
83
|
+
if (req.status !== 0) return { ok: false, detail: `openssl req failed: ${req.stderr.trim().split('\n')[0] ?? ''}` }
|
|
84
|
+
const exp = run(SYSTEM_OPENSSL, [
|
|
85
|
+
'pkcs12', '-export', '-inkey', key, '-in', cert, '-out', p12,
|
|
86
|
+
'-passout', `pass:${pass}`, '-name', SIGNING_IDENTITY_CN,
|
|
87
|
+
])
|
|
88
|
+
if (exp.status !== 0) return { ok: false, detail: `openssl pkcs12 failed: ${exp.stderr.trim().split('\n')[0] ?? ''}` }
|
|
89
|
+
// -T /usr/bin/codesign pre-authorizes codesign in the key's ACL — at most ONE
|
|
90
|
+
// keychain confirmation at the very first signing (the install-time event).
|
|
91
|
+
const imp = run('security', ['import', p12, '-P', pass, '-T', '/usr/bin/codesign'])
|
|
92
|
+
if (imp.status !== 0) return { ok: false, detail: `security import failed: ${imp.stderr.trim().split('\n')[0] ?? ''}` }
|
|
93
|
+
return { ok: true }
|
|
94
|
+
} finally {
|
|
95
|
+
try {
|
|
96
|
+
rmSync(dir, { recursive: true, force: true })
|
|
97
|
+
} catch {
|
|
98
|
+
/* best-effort cleanup of the throwaway key material */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Re-sign the installed binary with the stable local identity (creating the
|
|
105
|
+
* identity on first use). Called by installIapeer after the atomic rename —
|
|
106
|
+
* i.e. on EVERY install/update path, so the designated requirement (and with it
|
|
107
|
+
* every TCC grant) stays constant while the bytes change.
|
|
108
|
+
*/
|
|
109
|
+
export function signInstalledBinary(
|
|
110
|
+
binPath: string,
|
|
111
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
112
|
+
run: SigningRunner = defaultRunner,
|
|
113
|
+
): SigningOutcome {
|
|
114
|
+
// Keychain + codesign are HOST-GLOBAL — same fail-closed double-check as the
|
|
115
|
+
// launchctl guards: consult both the passed env and the process env.
|
|
116
|
+
if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') {
|
|
117
|
+
return { state: 'skipped-sandbox', detail: 'IAPEER_TEST_SANDBOX=1 — not touching the real keychain' }
|
|
118
|
+
}
|
|
119
|
+
let created = false
|
|
120
|
+
if (!identityPresent(run)) {
|
|
121
|
+
const c = createIdentity(run)
|
|
122
|
+
if (!c.ok) {
|
|
123
|
+
return { state: 'failed-soft', detail: `${c.detail} — binary stays ad-hoc-signed (works, but TCC prompts will re-appear after updates)` }
|
|
124
|
+
}
|
|
125
|
+
created = true
|
|
126
|
+
}
|
|
127
|
+
const sign = run('codesign', ['-f', '-s', SIGNING_IDENTITY_CN, '--identifier', SIGNING_IDENTIFIER, binPath])
|
|
128
|
+
if (sign.status !== 0) {
|
|
129
|
+
return {
|
|
130
|
+
state: 'failed-soft',
|
|
131
|
+
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)`,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return created ? { state: 'signed-new-identity' } : { state: 'signed' }
|
|
135
|
+
}
|
package/src/onboard/index.ts
CHANGED
|
@@ -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 (
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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 —
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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 (
|
|
119
|
-
//
|
|
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 (
|
|
129
|
+
: { ok: false, detail: (r.stderr ?? '').trim() || (r.status === null ? 'timed out (wedged runtime CLI?)' : `exit ${r.status}`) }
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
/**
|