threadwire 0.1.15 → 0.1.18

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.
@@ -105,6 +105,19 @@ export class DelegatedResultAdmission {
105
105
  return this.createEnvelope(terminal)
106
106
  }
107
107
 
108
+ /** @param {{state: TerminalState, exitCode: number}} terminal */
109
+ createProviderResult(terminal) {
110
+ const result = this.createEnvelope(terminal)
111
+ const assistantContent = truncateBoundedString(sanitizeOutput(this.conclusion), CONCLUSION_LIMIT)
112
+ return {
113
+ version: 1,
114
+ type: "provider_result",
115
+ providerState: terminal.state === "completed" ? "provider_completed" : "provider_failed",
116
+ ...(assistantContent === undefined ? {} : {assistantContent}),
117
+ result
118
+ }
119
+ }
120
+
108
121
  /** @param {AdmissionReference} reference */
109
122
  addReference(reference) {
110
123
  this.references = /** @type {AdmissionReference[]} */ (optionalReferences([...this.references, reference]) ?? [])
@@ -30,6 +30,9 @@ const DEFAULT_LIMITS = Object.freeze({
30
30
  retentionMs: 7 * 24 * 60 * 60 * 1000
31
31
  })
32
32
  const TEXT_CONTENT_TYPES = new Set(["application/json", "application/x-ndjson"])
33
+ const DELIVERY_STATES = new Set([
34
+ "delivery_pending", "delivery_succeeded", "delivery_failed", "delivery_not_requested"
35
+ ])
33
36
 
34
37
  export class EvidenceAccessError extends Error {
35
38
  constructor() {
@@ -80,8 +83,6 @@ export class EvidenceStore {
80
83
  this.capabilities = new Map()
81
84
  /** @type {WeakSet<object>} */
82
85
  this.scopes = new WeakSet()
83
- /** @type {Map<string, Set<string>>} */
84
- this.redactions = new Map()
85
86
  /** @type {Map<string, number>} */
86
87
  this.activeReaders = new Map()
87
88
  this.storeBytes = baseBytes
@@ -177,7 +178,7 @@ export class EvidenceStore {
177
178
 
178
179
  /**
179
180
  * @param {{destinationId: string, runId: string}} owner
180
- * @param {{contentType: string, redactions?: string[]}} options
181
+ * @param {{contentType: string, redactions?: string[], delivery?: EvidenceDelivery}} options
181
182
  */
182
183
  async createArtifact(owner, options) {
183
184
  this.assertScope(owner)
@@ -186,13 +187,15 @@ export class EvidenceStore {
186
187
  const id = randomBytes(32).toString("base64url")
187
188
  const handle = `evidence_${randomBytes(32).toString("base64url")}`
188
189
  const now = this.clock()
190
+ const delivery = options.delivery === undefined ? undefined : validateDelivery(options.delivery)
189
191
  const metadata = /** @type {EvidenceMetadata} */ ({
190
192
  version: 2, id, state: "pending", owner: {...owner}, contentType,
191
193
  capabilityHash: capabilityHash(handle),
192
194
  policyVersion: 1, framingVersion: 1,
193
195
  redactionPolicy: {algorithm: "aes-256-gcm", nonce: "", ciphertext: "", tag: ""},
194
196
  payloadCommitment: "A".repeat(43),
195
- bytes: 0, storedBytes: 0, lines: 0, events: 0, createdAt: now, expiresAt: now + this.limits.retentionMs
197
+ bytes: 0, storedBytes: 0, lines: 0, events: 0, createdAt: now, expiresAt: now + this.limits.retentionMs,
198
+ ...(delivery === undefined ? {} : {delivery})
196
199
  })
197
200
  metadata.redactionPolicy = encryptRedactions(handle, redactions, metadata)
198
201
  const overheadBytes = durableArtifactReservation(metadata)
@@ -230,11 +233,10 @@ export class EvidenceStore {
230
233
  if (!file) throw new Error("Evidence pending payload is unavailable")
231
234
  this.artifacts.set(id, metadata)
232
235
  this.capabilities.set(metadata.capabilityHash, id)
233
- this.redactions.set(id, new Set(redactions))
234
236
  return new EvidenceWriter(this, metadata, file, handle, finishOperation, redactions, owner)
235
237
  }
236
238
 
237
- /** @param {object} owner @param {unknown} request */
239
+ /** @param {object} owner @param {unknown} request @returns {Promise<EvidenceReadResult>} */
238
240
  async read(owner, request) {
239
241
  this.assertScope(owner)
240
242
  const validated = validateEvidenceReadRequest(request, this.limits)
@@ -284,14 +286,16 @@ export class EvidenceStore {
284
286
  data: binary.toString("base64"),
285
287
  encoding: "base64",
286
288
  redacted,
287
- truncated: truncated || binary.length < selected.length
289
+ truncated: truncated || binary.length < selected.length,
290
+ ...deliveryResult(metadata)
288
291
  }, this.limits.maxReadBytes)
289
292
  }
290
293
  return fitSerializedResult({
291
294
  contentType: metadata.contentType,
292
295
  data: decodeUtf8(selected),
293
296
  redacted,
294
- truncated
297
+ truncated,
298
+ ...deliveryResult(metadata)
295
299
  }, this.limits.maxReadBytes)
296
300
  } finally {
297
301
  const readers = (this.activeReaders.get(id) ?? 1) - 1
@@ -301,7 +305,7 @@ export class EvidenceStore {
301
305
  }
302
306
  }
303
307
 
304
- /** @param {unknown} request */
308
+ /** @param {unknown} request @returns {Promise<EvidenceReadResult>} */
305
309
  async readBearer(request) {
306
310
  const validated = validateEvidenceReadRequest(request, this.limits)
307
311
  const id = this.lookupCapability(validated.handle)
@@ -317,7 +321,44 @@ export class EvidenceStore {
317
321
  }
318
322
  }
319
323
 
320
- /** @param {{chatId: string, threadId: number | null, senderId: string}} requester @param {unknown} request */
324
+ /** @param {object} owner @param {string} handle @param {"delivery_succeeded" | "delivery_failed"} state */
325
+ recordDelivery(owner, handle, state) {
326
+ const finish = this.beginOperation()
327
+ return this.enqueueMaintenance(() => this.recordDeliveryOnce(owner, handle, state)).finally(finish)
328
+ }
329
+
330
+ /** @param {object} owner @param {string} handle @param {"delivery_succeeded" | "delivery_failed"} state */
331
+ async recordDeliveryOnce(owner, handle, state) {
332
+ await this.assertRootIdentity()
333
+ this.assertScope(owner)
334
+ if (state !== "delivery_succeeded" && state !== "delivery_failed") throw new Error("Invalid terminal delivery state")
335
+ const id = this.lookupCapability(handle)
336
+ if (!id) throw new EvidenceAccessError()
337
+ const metadata = this.artifacts.get(id)
338
+ if (!metadata || metadata.state !== "ready" || !sameOwner(metadata.owner, owner) || metadata.delivery === undefined) {
339
+ throw new EvidenceAccessError()
340
+ }
341
+ if (metadata.delivery.state !== "delivery_pending") throw new Error("Evidence delivery state is already terminal")
342
+ const redactions = decryptRedactions(handle, metadata.redactionPolicy, metadata)
343
+ const updated = /** @type {EvidenceMetadata} */ ({
344
+ ...metadata,
345
+ delivery: {identity: metadata.delivery.identity, state}
346
+ })
347
+ updated.reservedBytes = durableArtifactReservation(updated)
348
+ updated.redactionPolicy = encryptRedactions(handle, redactions, updated)
349
+ const reservationDelta = updated.reservedBytes - metadata.reservedBytes
350
+ if (reservationDelta > 0) this.reserve(metadata.owner.runId, reservationDelta, 0)
351
+ try {
352
+ await atomicJson(this.root, `${id}${META_SUFFIX}`, updated)
353
+ } catch (error) {
354
+ if (reservationDelta > 0) this.reserve(metadata.owner.runId, -reservationDelta, 0)
355
+ throw error
356
+ }
357
+ if (reservationDelta < 0) this.reserve(metadata.owner.runId, reservationDelta, 0)
358
+ this.artifacts.set(id, updated)
359
+ }
360
+
361
+ /** @param {{chatId: string, threadId: number | null, senderId: string}} requester @param {unknown} request @returns {Promise<EvidenceReadResult>} */
321
362
  async readTelegram(requester, request) {
322
363
  const validated = validateEvidenceReadRequest(request, this.limits)
323
364
  const id = this.lookupCapability(validated.handle)
@@ -352,7 +393,6 @@ export class EvidenceStore {
352
393
  this.capabilities.delete(metadata.capabilityHash)
353
394
  this.release(metadata)
354
395
  this.artifactSlots -= 1
355
- this.redactions.delete(id)
356
396
  }
357
397
  }
358
398
 
@@ -379,7 +419,6 @@ export class EvidenceStore {
379
419
  this.capabilities.delete(metadata.capabilityHash)
380
420
  this.release(metadata)
381
421
  this.artifactSlots -= 1
382
- this.redactions.delete(id)
383
422
  }
384
423
 
385
424
  /** @template T @param {() => Promise<T>} operation @returns {Promise<T>} */
@@ -435,7 +474,6 @@ export class EvidenceStore {
435
474
  this.artifactSlots += 1
436
475
  this.artifacts.set(id, metadata)
437
476
  this.capabilities.set(metadata.capabilityHash, id)
438
- this.redactions.set(id, new Set(this.runtimeRedactions))
439
477
  } catch {
440
478
  await Promise.all([...files].map((suffix) => safeUnlink(this.path(id, suffix))))
441
479
  }
@@ -601,7 +639,6 @@ class EvidenceWriter {
601
639
  this.store.artifactSlots -= 1
602
640
  this.store.artifacts.delete(this.metadata.id)
603
641
  this.store.capabilities.delete(this.metadata.capabilityHash)
604
- this.store.redactions.delete(this.metadata.id)
605
642
  await safeUnlink(this.store.path(this.metadata.id, PENDING_SUFFIX))
606
643
  await safeUnlink(this.store.path(this.metadata.id, PAYLOAD_SUFFIX))
607
644
  await safeUnlink(this.store.path(this.metadata.id, META_SUFFIX))
@@ -632,7 +669,6 @@ class EvidenceWriter {
632
669
  this.store.artifactSlots -= 1
633
670
  this.store.artifacts.delete(this.metadata.id)
634
671
  this.store.capabilities.delete(this.metadata.capabilityHash)
635
- this.store.redactions.delete(this.metadata.id)
636
672
  await safeUnlink(this.store.path(this.metadata.id, PENDING_SUFFIX))
637
673
  await safeUnlink(this.store.path(this.metadata.id, META_SUFFIX))
638
674
  await syncDirectory(this.store.root)
@@ -875,7 +911,7 @@ function utf8Prefix(buffer, limit) {
875
911
  return buffer.subarray(0, limit)
876
912
  }
877
913
 
878
- /** @param {{contentType: string, data: string, encoding?: string, redacted: boolean, truncated: boolean}} result @param {number} limit */
914
+ /** @param {EvidenceReadResult} result @param {number} limit @returns {EvidenceReadResult} */
879
915
  function fitSerializedResult(result, limit) {
880
916
  if (Buffer.byteLength(`${JSON.stringify(result)}\n`) <= limit) return result
881
917
  let bytes = Buffer.from(result.data, "utf8")
@@ -1313,14 +1349,16 @@ function decryptRedactions(handle, policy, metadata) {
1313
1349
 
1314
1350
  /** @param {EvidenceMetadata} metadata */
1315
1351
  function redactionAad(metadata) {
1316
- return Buffer.from(JSON.stringify([
1352
+ const values = [
1317
1353
  "threadwire-evidence-policy", metadata.policyVersion, metadata.version, metadata.framingVersion, metadata.id,
1318
1354
  metadata.state,
1319
1355
  metadata.capabilityHash, metadata.owner.destinationId, metadata.owner.runId,
1320
1356
  metadata.contentType, metadata.createdAt, metadata.expiresAt,
1321
1357
  metadata.bytes, metadata.storedBytes, metadata.reservedBytes,
1322
1358
  metadata.lines, metadata.events, metadata.payloadCommitment
1323
- ]), "utf8")
1359
+ ]
1360
+ if (metadata.delivery !== undefined) values.push(metadata.delivery.identity, metadata.delivery.state)
1361
+ return Buffer.from(JSON.stringify(values), "utf8")
1324
1362
  }
1325
1363
 
1326
1364
  /** @param {EvidenceMetadata} metadata */
@@ -1374,6 +1412,22 @@ function validateRedactions(values) {
1374
1412
  return values
1375
1413
  }
1376
1414
 
1415
+ /** @param {unknown} value @returns {EvidenceDelivery} */
1416
+ function validateDelivery(value) {
1417
+ if (
1418
+ !plainObject(value) || !exactDataKeys(value, ["identity", "state"])
1419
+ || typeof value.identity !== "string"
1420
+ || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value.identity)
1421
+ || typeof value.state !== "string" || !DELIVERY_STATES.has(value.state)
1422
+ ) throw new Error("Invalid evidence delivery metadata")
1423
+ return /** @type {EvidenceDelivery} */ ({identity: value.identity, state: value.state})
1424
+ }
1425
+
1426
+ /** @param {EvidenceMetadata} metadata */
1427
+ function deliveryResult(metadata) {
1428
+ return metadata.delivery === undefined ? {} : {delivery: {...metadata.delivery}}
1429
+ }
1430
+
1377
1431
  /** @param {Record<string, unknown>} left @param {object} right */
1378
1432
  function sameOwner(left, right) {
1379
1433
  return left.destinationId === /** @type {{destinationId?: unknown}} */ (right).destinationId
@@ -1382,11 +1436,13 @@ function sameOwner(left, right) {
1382
1436
 
1383
1437
  /** @param {unknown} value @param {string} id @param {EvidenceLimits} limits */
1384
1438
  function validMetadata(value, id, limits) {
1439
+ const metadataKeys = [
1440
+ "version", "policyVersion", "framingVersion", "id", "state", "owner", "contentType", "capabilityHash",
1441
+ "redactionPolicy", "payloadCommitment", "bytes", "storedBytes", "reservedBytes", "lines", "events", "createdAt", "expiresAt"
1442
+ ]
1443
+ if (plainObject(value) && value.delivery !== undefined) metadataKeys.push("delivery")
1385
1444
  return plainObject(value) && value.version === 2 && value.id === id && value.state === "ready"
1386
- && exactKeys(value, [
1387
- "version", "policyVersion", "framingVersion", "id", "state", "owner", "contentType", "capabilityHash",
1388
- "redactionPolicy", "payloadCommitment", "bytes", "storedBytes", "reservedBytes", "lines", "events", "createdAt", "expiresAt"
1389
- ])
1445
+ && exactKeys(value, metadataKeys)
1390
1446
  && value.policyVersion === 1 && value.framingVersion === 1
1391
1447
  && plainObject(value.owner) && typeof value.owner.destinationId === "string" && validIdentity(value.owner.destinationId)
1392
1448
  && typeof value.owner.runId === "string" && validIdentity(value.owner.runId)
@@ -1404,6 +1460,17 @@ function validMetadata(value, id, limits) {
1404
1460
  && typeof value.events === "number" && nonnegative(value.events) && value.events <= limits.maxArtifactEvents
1405
1461
  && typeof value.createdAt === "number" && typeof value.expiresAt === "number"
1406
1462
  && nonnegative(value.createdAt) && positive(value.expiresAt) && value.createdAt < value.expiresAt
1463
+ && (value.delivery === undefined || validDelivery(value.delivery))
1464
+ }
1465
+
1466
+ /** @param {unknown} value */
1467
+ function validDelivery(value) {
1468
+ try {
1469
+ validateDelivery(value)
1470
+ return true
1471
+ } catch {
1472
+ return false
1473
+ }
1407
1474
  }
1408
1475
 
1409
1476
  /** @param {unknown} value */
@@ -1455,8 +1522,11 @@ function validateLimits(limits) {
1455
1522
  * owner: {destinationId: string, runId: string}, contentType: string,
1456
1523
  * capabilityHash: string,
1457
1524
  * redactionPolicy: {algorithm: "aes-256-gcm", nonce: string, ciphertext: string, tag: string}, payloadCommitment: string,
1458
- * bytes: number, storedBytes: number, reservedBytes: number, lines: number, events: number, createdAt: number, expiresAt: number
1525
+ * bytes: number, storedBytes: number, reservedBytes: number, lines: number, events: number, createdAt: number, expiresAt: number,
1526
+ * delivery?: EvidenceDelivery
1459
1527
  * }} EvidenceMetadata
1528
+ * @typedef {{identity: string, state: "delivery_pending" | "delivery_succeeded" | "delivery_failed" | "delivery_not_requested"}} EvidenceDelivery
1529
+ * @typedef {{contentType: string, data: string, encoding?: string, redacted: boolean, truncated: boolean, delivery?: EvidenceDelivery}} EvidenceReadResult
1460
1530
  * @typedef {{
1461
1531
  * handle: string,
1462
1532
  * bytes?: {offset: number, limit: number},
@@ -1,10 +1,9 @@
1
1
  // @ts-check
2
2
 
3
3
  import {spawn} from "node:child_process"
4
- import {providerExecutable} from "./providers/executable.js"
4
+ import {codexExecutable} from "./providers/executable.js"
5
5
  import {CapacityProbeError, DEFAULT_CAPACITY_TIMEOUT_MS, normalizeCodexRateLimits} from "./provider-capacity.js"
6
6
 
7
- const DEFAULT_EXECUTABLE = "/opt/data/libexec/threadwire/codex"
8
7
  const MAX_LINE_BYTES = 65_536
9
8
  const MAX_LINES = 1_024
10
9
 
@@ -16,6 +15,8 @@ const MAX_LINES = 1_024
16
15
  * failures classify with fixed, credential-free messages.
17
16
  * @param {{
18
17
  * env?: NodeJS.ProcessEnv,
18
+ * executable?: string | undefined,
19
+ * executableResolution?: Parameters<typeof codexExecutable>[1],
19
20
  * spawnImplementation?: typeof spawn | undefined,
20
21
  * timeoutMs?: number
21
22
  * }} options
@@ -25,7 +26,7 @@ export async function probeCodexCapacity(options) {
25
26
  const environment = options.env ?? process.env
26
27
  const spawnImplementation = options.spawnImplementation ?? spawn
27
28
  const timeoutMs = options.timeoutMs ?? DEFAULT_CAPACITY_TIMEOUT_MS
28
- const executable = providerExecutable("THREADWIRE_CODEX_BIN", DEFAULT_EXECUTABLE, environment)
29
+ const executable = options.executable ?? codexExecutable(environment, options.executableResolution)
29
30
  const result = await appServerExchange(spawnImplementation, executable, environment, timeoutMs)
30
31
  return normalizeCodexRateLimits(result)
31
32
  }
@@ -1,16 +1,20 @@
1
1
  // @ts-check
2
2
 
3
- import {providerExecutable} from "./executable.js"
3
+ import {codexExecutable} from "./executable.js"
4
4
 
5
- const DEFAULT_EXECUTABLE = "/opt/data/libexec/threadwire/codex"
6
-
7
- /** @param {string[]} providerArguments @param {string} prompt @param {string | undefined} [resumeSession] */
8
- export function buildCodexCommand(providerArguments, prompt, resumeSession, environment = process.env) {
5
+ /**
6
+ * @param {string[]} providerArguments
7
+ * @param {string} prompt
8
+ * @param {string | undefined} [resumeSession]
9
+ * @param {NodeJS.ProcessEnv} [environment]
10
+ * @param {Parameters<typeof codexExecutable>[1]} [executableResolution]
11
+ */
12
+ export function buildCodexCommand(providerArguments, prompt, resumeSession, environment = process.env, executableResolution = {}) {
9
13
  rejectOwnedArguments(providerArguments)
10
14
  const arguments_ = resumeSession === undefined
11
15
  ? ["exec", "--json", ...providerArguments, prompt]
12
16
  : ["exec", "resume", "--json", ...providerArguments, resumeSession, prompt]
13
- return {executable: providerExecutable("THREADWIRE_CODEX_BIN", DEFAULT_EXECUTABLE, environment), arguments: arguments_}
17
+ return {executable: codexExecutable(environment, executableResolution), arguments: arguments_}
14
18
  }
15
19
 
16
20
  /** @param {string[]} arguments_ */
@@ -1,10 +1,18 @@
1
1
  // @ts-check
2
2
 
3
+ import {accessSync, constants, realpathSync, statSync} from "node:fs"
4
+ import {resolve} from "node:path"
5
+
6
+ const CODEX_STRUCTURAL_EXECUTABLE = "/opt/data/libexec/threadwire/codex"
7
+ const CODEX_FRONT_DOOR_EXECUTABLES = new Set(["/opt/data/bin/codex"])
8
+
9
+ /** @typedef {(path: string) => boolean} ExecutableProbe */
10
+ /** @typedef {{canonicalize?: ((path: string) => string) | undefined, cwd?: string | undefined, isExecutable?: ExecutableProbe | undefined, warn?: ((warning: string) => void) | undefined}} ExecutableResolutionOptions */
11
+
3
12
  /**
4
13
  * Resolve the terminal provider executable Threadwire spawns. The default is the
5
- * structurally separate libexec adapter (never the /opt/data/bin front-door), so
6
- * relaying can never recurse. A non-empty environment override supports staged
7
- * cutover and rollback (for example, pointing back at the previous shim path).
14
+ * real provider CLI. A non-empty environment override supports staged cutover
15
+ * and rollback, but only when the override path exists and is executable.
8
16
  * @param {string} overrideVariable
9
17
  * @param {string} defaultPath
10
18
  * @param {NodeJS.ProcessEnv} [environment]
@@ -12,5 +20,85 @@
12
20
  */
13
21
  export function providerExecutable(overrideVariable, defaultPath, environment = process.env) {
14
22
  const override = environment[overrideVariable]
15
- return override !== undefined && override.length > 0 ? override : defaultPath
23
+ if (override !== undefined && override.length > 0) {
24
+ try {
25
+ accessSync(override, constants.X_OK)
26
+ if (!statSync(override).isFile()) {
27
+ console.error(`threadwire: ${overrideVariable}=${override} is a directory, falling back to ${defaultPath}`)
28
+ return defaultPath
29
+ }
30
+ return override
31
+ } catch {
32
+ console.error(`threadwire: ${overrideVariable}=${override} is not executable, falling back to ${defaultPath}`)
33
+ }
34
+ }
35
+ return defaultPath
36
+ }
37
+
38
+ /**
39
+ * Resolve Codex without consulting a bare command through spawn. A valid
40
+ * override wins, followed by the structural adapter and then executable PATH
41
+ * candidates in Unix lookup order. Known Threadwire front doors are skipped so
42
+ * a normal direct-CLI lookup cannot recurse into Threadwire.
43
+ * @param {NodeJS.ProcessEnv} [environment]
44
+ * @param {ExecutableResolutionOptions} [options]
45
+ * @returns {string}
46
+ */
47
+ export function codexExecutable(environment = process.env, options = {}) {
48
+ const isExecutable = options.isExecutable ?? executableFile
49
+ const warning = options.warn ?? console.error
50
+ const canonicalize = options.canonicalize ?? realpathSync
51
+ const cwd = options.cwd ?? process.cwd()
52
+ const override = environment.THREADWIRE_CODEX_BIN
53
+ if (override !== undefined && override.length > 0 && isExecutable(override)) return override
54
+
55
+ const fallback = discoveredCodexExecutable(environment, cwd, isExecutable, canonicalize) ?? CODEX_STRUCTURAL_EXECUTABLE
56
+ if (override !== undefined && override.length > 0) {
57
+ warning(`threadwire: THREADWIRE_CODEX_BIN=${override} is not executable, falling back to ${fallback}`)
58
+ }
59
+ return fallback
60
+ }
61
+
62
+ /**
63
+ * @param {NodeJS.ProcessEnv} environment
64
+ * @param {string} cwd
65
+ * @param {ExecutableProbe} isExecutable
66
+ * @param {(path: string) => string} canonicalize
67
+ * @returns {string | undefined}
68
+ */
69
+ function discoveredCodexExecutable(environment, cwd, isExecutable, canonicalize) {
70
+ if (isExecutable(CODEX_STRUCTURAL_EXECUTABLE)) return CODEX_STRUCTURAL_EXECUTABLE
71
+ if (environment.PATH === undefined) return undefined
72
+ for (const directory of environment.PATH.split(":")) {
73
+ const candidate = resolve(cwd, directory, "codex")
74
+ if (CODEX_FRONT_DOOR_EXECUTABLES.has(candidate)) continue
75
+ const canonicalPath = canonicalExecutable(candidate, isExecutable, canonicalize)
76
+ if (canonicalPath !== undefined && !CODEX_FRONT_DOOR_EXECUTABLES.has(canonicalPath)) return candidate
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ /**
82
+ * @param {string} path
83
+ * @param {ExecutableProbe} isExecutable
84
+ * @param {(path: string) => string} canonicalize
85
+ * @returns {string | undefined}
86
+ */
87
+ function canonicalExecutable(path, isExecutable, canonicalize) {
88
+ if (!isExecutable(path)) return undefined
89
+ try {
90
+ return canonicalize(path)
91
+ } catch {
92
+ return undefined
93
+ }
94
+ }
95
+
96
+ /** @type {ExecutableProbe} */
97
+ function executableFile(path) {
98
+ try {
99
+ accessSync(path, constants.X_OK)
100
+ return statSync(path).isFile()
101
+ } catch {
102
+ return false
103
+ }
16
104
  }
@@ -13,10 +13,10 @@ import {buildOpenCodeCommand, createOpenCodeParser, createOpenCodeSessionId} fro
13
13
  /** @type {readonly ["codex", "claude", "kimi", "opencode"]} */
14
14
  export const PROVIDERS = ["codex", "claude", "kimi", "opencode"]
15
15
 
16
- /** @param {string} name @param {string[]} providerArguments @param {string} prompt @param {string | undefined} resumeSession @param {NodeJS.ProcessEnv} [environment] @returns {Provider} */
17
- export function createProvider(name, providerArguments, prompt, resumeSession, environment = process.env) {
16
+ /** @param {string} name @param {string[]} providerArguments @param {string} prompt @param {string | undefined} resumeSession @param {NodeJS.ProcessEnv} [environment] @param {Parameters<typeof buildCodexCommand>[4]} [codexExecutableResolution] @returns {Provider} */
17
+ export function createProvider(name, providerArguments, prompt, resumeSession, environment = process.env, codexExecutableResolution = {}) {
18
18
  if (name === "codex") {
19
- const command = buildCodexCommand(providerArguments, prompt, resumeSession, environment)
19
+ const command = buildCodexCommand(providerArguments, prompt, resumeSession, environment, codexExecutableResolution)
20
20
  return {...command, name: "codex", parse: parseCodexEvent, sessionId: codexSessionId, health: extractProviderHealth}
21
21
  }
22
22
  if (name === "claude") {