@gotcos/glasses-server 6.36.12 → 6.36.13
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
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
## Unreleased
|
|
2
2
|
|
|
3
|
+
## 6.36.13
|
|
4
|
+
- **Codex was spawned by bare name in four places, and it only worked here by accident.**
|
|
5
|
+
`codex` on PATH is a shell alias to `/Applications/Codex.app`, which does not exist; the real
|
|
6
|
+
binary lives in ChatGPT.app. Every bare `spawn('codex', …)` resolved through a PATH that COS
|
|
7
|
+
Control injects into the managed plist — so it worked on this machine and was ENOENT for every
|
|
8
|
+
public npx user, and for anything launchd- or Finder-spawned. The sites: the model catalog, the
|
|
9
|
+
`--add-dir` capability probe, the health probe, and `callCodexStreaming`, which is the **live
|
|
10
|
+
turn-execution path**. Each now resolves first, and each refuses in the way that suits it: the
|
|
11
|
+
live turn throws with the reason, the catalog rejects to its existing `cli-default` degradation,
|
|
12
|
+
the capability probe reports unsupported, and the health probe reports `unresolved (…)` instead
|
|
13
|
+
of collapsing "cannot find it" and "found it and it errored" into one `error`.
|
|
14
|
+
- **Binary resolution moved to its own leaf module.** `provider-binary.ts` imports nothing from the
|
|
15
|
+
repo, deliberately: reaching the resolver through `attached-provider-adapter.ts` would close the
|
|
16
|
+
cycle adapter → codex-run-ledger → codex-model-catalog → adapter. The adapter re-exports it, so
|
|
17
|
+
no existing importer changed.
|
|
18
|
+
- **Three source-text assertions replaced with fixtures.** Two tests asserted on this repo's own
|
|
19
|
+
characters — `not.toMatch('AGENT_SESSION_MAX_FILE_BYTES')` and `toMatch('end = HEAD_BYTES - 1')`
|
|
20
|
+
— which go stale on any refactor and cannot observe the property they name. A Codex rollout is
|
|
21
|
+
now made genuinely larger than the 32 MB gate (sparse, via `truncateSync`) and asserted to still
|
|
22
|
+
be listed. Verified by mutation: adding that gate to `listCodexSessions` fails the new test.
|
|
23
|
+
|
|
3
24
|
## 6.36.12
|
|
4
25
|
- **Claude and Codex now rank candidates by recency before spending the budget.** Both
|
|
5
26
|
walked in raw `readdir` order, so which sessions were reachable came down to filesystem
|
package/package.json
CHANGED
|
@@ -227,173 +227,28 @@ export function attachedDeliveryAmbiguous(result: AttachedTurnResult): boolean {
|
|
|
227
227
|
// ---------------------------------------------------------------------------
|
|
228
228
|
// Binary resolution
|
|
229
229
|
// ---------------------------------------------------------------------------
|
|
230
|
+
//
|
|
231
|
+
// MOVED to ./provider-binary.js so codex-bridge, codex-model-catalog and
|
|
232
|
+
// health-static-probes can resolve without importing this module -- doing so would close
|
|
233
|
+
// the cycle adapter -> codex-run-ledger -> codex-model-catalog -> adapter.
|
|
234
|
+
// Re-exported here so existing importers and tests are unaffected.
|
|
235
|
+
|
|
236
|
+
import {
|
|
237
|
+
resolveProviderBinary,
|
|
238
|
+
type BinaryResolution,
|
|
239
|
+
} from './provider-binary.js'
|
|
240
|
+
|
|
241
|
+
export {
|
|
242
|
+
STALE_SHIM_PREFIXES,
|
|
243
|
+
isKnownStaleShimPath,
|
|
244
|
+
providerBinarySpec,
|
|
245
|
+
resolveProviderBinary,
|
|
246
|
+
resolveBinaryFromSpec,
|
|
247
|
+
type BinaryResolution,
|
|
248
|
+
type BinaryResolutionFailure,
|
|
249
|
+
type BinarySpec,
|
|
250
|
+
} from './provider-binary.js'
|
|
230
251
|
|
|
231
|
-
export type BinaryResolutionFailure = 'env_override_unusable' | 'not_found'
|
|
232
|
-
|
|
233
|
-
export type BinaryResolution =
|
|
234
|
-
| { ok: true; path: string; source: 'env' | 'absolute' | 'path' }
|
|
235
|
-
| { ok: false; binary: string; detail: BinaryResolutionFailure }
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Path prefixes that can never be a usable provider binary.
|
|
239
|
-
*
|
|
240
|
-
* `codex` resolved through PATH (or a shell alias) points at
|
|
241
|
-
* `/Applications/Codex.app/Contents/Resources/codex` on this machine, and that
|
|
242
|
-
* app no longer exists. Today the shim is dangling, so an existence check
|
|
243
|
-
* already rejects it — but a reinstalled or partially-removed Codex.app puts a
|
|
244
|
-
* real, executable file back on that path, and then only this list stands
|
|
245
|
-
* between an attached turn and a binary that cannot serve it.
|
|
246
|
-
*/
|
|
247
|
-
export const STALE_SHIM_PREFIXES: readonly string[] = ['/Applications/Codex.app/']
|
|
248
|
-
|
|
249
|
-
export function isKnownStaleShimPath(
|
|
250
|
-
candidate: string,
|
|
251
|
-
prefixes: readonly string[] = STALE_SHIM_PREFIXES,
|
|
252
|
-
): boolean {
|
|
253
|
-
// A non-array argument falls back to the known list rather than to "exclude
|
|
254
|
-
// nothing" — the classic version of this bug is `list.some(isKnownStaleShimPath)`,
|
|
255
|
-
// where `.some` passes the INDEX as the second argument and every exclusion
|
|
256
|
-
// silently disappears.
|
|
257
|
-
const list = Array.isArray(prefixes) ? prefixes : STALE_SHIM_PREFIXES
|
|
258
|
-
return list.some(prefix => typeof prefix === 'string' && prefix.length > 0 && candidate.startsWith(prefix))
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function isUsableExecutable(
|
|
262
|
-
candidate: string,
|
|
263
|
-
excludePrefixes: readonly string[] = STALE_SHIM_PREFIXES,
|
|
264
|
-
): boolean {
|
|
265
|
-
try {
|
|
266
|
-
if (!isAbsolute(candidate) || candidate.includes('\0')) return false
|
|
267
|
-
if (isKnownStaleShimPath(candidate, excludePrefixes)) return false
|
|
268
|
-
if (!statSync(candidate).isFile()) return false
|
|
269
|
-
accessSync(candidate, fsConstants.X_OK)
|
|
270
|
-
return true
|
|
271
|
-
} catch {
|
|
272
|
-
return false
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
export interface BinarySpec {
|
|
277
|
-
name: string
|
|
278
|
-
envKeys: readonly string[]
|
|
279
|
-
/** Tried in order, BEFORE any PATH scan. */
|
|
280
|
-
absolutes: readonly string[]
|
|
281
|
-
/**
|
|
282
|
-
* Paths that must never be selected, from any source. Defaults to
|
|
283
|
-
* `STALE_SHIM_PREFIXES`.
|
|
284
|
-
*
|
|
285
|
-
* On the spec rather than hardcoded because the exclusion is the only guard
|
|
286
|
-
* standing between resolution and a known-bad binary, and a guard whose input
|
|
287
|
-
* cannot be constructed is a guard nobody can prove works: the real prefix
|
|
288
|
-
* lives under `/Applications`, which no fixture can write to.
|
|
289
|
-
*/
|
|
290
|
-
excludePrefixes?: readonly string[]
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Where each provider's binary is looked for, in precedence order.
|
|
295
|
-
*
|
|
296
|
-
* Exported as data rather than kept private so the precedence itself is
|
|
297
|
-
* testable: on a machine where a stale shim sits on PATH, "ChatGPT.app is tried
|
|
298
|
-
* before PATH" is the property that decides whether an attached Codex turn
|
|
299
|
-
* launches the real binary or a dangling one.
|
|
300
|
-
*/
|
|
301
|
-
export function providerBinarySpec(provider: AttachedProvider): BinarySpec {
|
|
302
|
-
const home = (() => {
|
|
303
|
-
try {
|
|
304
|
-
return homedir()
|
|
305
|
-
} catch {
|
|
306
|
-
return ''
|
|
307
|
-
}
|
|
308
|
-
})()
|
|
309
|
-
if (provider === 'claude') {
|
|
310
|
-
return {
|
|
311
|
-
name: 'claude',
|
|
312
|
-
envKeys: ['COS_ATTACHED_CLAUDE_BIN', 'COS_CLAUDE_BIN'],
|
|
313
|
-
absolutes: [
|
|
314
|
-
'/opt/homebrew/bin/claude',
|
|
315
|
-
'/usr/local/bin/claude',
|
|
316
|
-
home ? join(home, '.local', 'bin', 'claude') : '',
|
|
317
|
-
].filter(Boolean),
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
return {
|
|
321
|
-
name: 'codex',
|
|
322
|
-
envKeys: ['COS_ATTACHED_CODEX_BIN', 'COS_CODEX_BIN'],
|
|
323
|
-
absolutes: [
|
|
324
|
-
// Verified 2026-08-15: codex-cli 0.148.0-alpha.9 lives here, and there is
|
|
325
|
-
// no `codex` on PATH at all on this machine.
|
|
326
|
-
'/Applications/ChatGPT.app/Contents/Resources/codex',
|
|
327
|
-
home ? join(home, '.codex', 'bin', 'codex') : '',
|
|
328
|
-
'/opt/homebrew/bin/codex',
|
|
329
|
-
'/usr/local/bin/codex',
|
|
330
|
-
].filter(Boolean),
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Resolve a provider binary to a verified absolute path, or refuse.
|
|
336
|
-
*
|
|
337
|
-
* Never returns a bare name. A bare name is a silent PATH lookup, and the two
|
|
338
|
-
* environments this server runs in disagree about PATH: a login shell finds the
|
|
339
|
-
* CLI, a Finder- or launchd-spawned process gets a minimal PATH and does not
|
|
340
|
-
* (hit twice in COS Control). The PATH scan below is done by us, entry by
|
|
341
|
-
* entry, and still yields an absolute path we have stat'ed — so a failure names
|
|
342
|
-
* the missing binary instead of surfacing as ENOENT from inside a spawn.
|
|
343
|
-
*
|
|
344
|
-
* An unusable env override REFUSES rather than falling through to the
|
|
345
|
-
* candidates: an operator who set it wrongly needs to be told, not overridden.
|
|
346
|
-
*/
|
|
347
|
-
export function resolveProviderBinary(
|
|
348
|
-
provider: AttachedProvider,
|
|
349
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
350
|
-
): BinaryResolution {
|
|
351
|
-
return resolveBinaryFromSpec(providerBinarySpec(provider), env)
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* The resolution algorithm itself, separated from the provider tables.
|
|
356
|
-
*
|
|
357
|
-
* Not a testing seam bolted on: on any developer machine at least one real
|
|
358
|
-
* absolute candidate exists, so the PATH-scan and not-found branches of
|
|
359
|
-
* `resolveProviderBinary` are unreachable from a test and would ship
|
|
360
|
-
* unexercised — which is exactly how a launchd-only failure hides. Driving the
|
|
361
|
-
* REAL algorithm with a fixture spec exercises them for real.
|
|
362
|
-
*/
|
|
363
|
-
export function resolveBinaryFromSpec(spec: BinarySpec, env: NodeJS.ProcessEnv): BinaryResolution {
|
|
364
|
-
const excluded = spec.excludePrefixes ?? STALE_SHIM_PREFIXES
|
|
365
|
-
|
|
366
|
-
for (const key of spec.envKeys) {
|
|
367
|
-
const raw = env[key]
|
|
368
|
-
if (typeof raw !== 'string' || raw.trim().length === 0) continue
|
|
369
|
-
const candidate = raw.trim()
|
|
370
|
-
if (!isUsableExecutable(candidate, excluded)) {
|
|
371
|
-
return { ok: false, binary: spec.name, detail: 'env_override_unusable' }
|
|
372
|
-
}
|
|
373
|
-
return { ok: true, path: candidate, source: 'env' }
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
for (const candidate of spec.absolutes) {
|
|
377
|
-
if (isUsableExecutable(candidate, excluded)) return { ok: true, path: candidate, source: 'absolute' }
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
const pathValue = typeof env.PATH === 'string' ? env.PATH : ''
|
|
381
|
-
for (const dir of pathValue.split(delimiter)) {
|
|
382
|
-
// A relative PATH entry resolves against the server's cwd, which is not a
|
|
383
|
-
// location we control. Skipped rather than resolved.
|
|
384
|
-
//
|
|
385
|
-
// Redundant with the `isAbsolute` inside `isUsableExecutable` — verified by
|
|
386
|
-
// mutation: removing EITHER one alone changes no outcome, and only removing
|
|
387
|
-
// BOTH lets a relative entry through. Kept because two independent guards
|
|
388
|
-
// on "never resolve against the server cwd" is the correct amount for a
|
|
389
|
-
// path that ends up as a spawned executable.
|
|
390
|
-
if (!dir || !isAbsolute(dir)) continue
|
|
391
|
-
const candidate = join(dir, spec.name)
|
|
392
|
-
if (isUsableExecutable(candidate, excluded)) return { ok: true, path: candidate, source: 'path' }
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
return { ok: false, binary: spec.name, detail: 'not_found' }
|
|
396
|
-
}
|
|
397
252
|
|
|
398
253
|
// ---------------------------------------------------------------------------
|
|
399
254
|
// Injected surface
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
// Concrete GPT ids resolve from Codex's live model catalog at run time.
|
|
3
3
|
|
|
4
4
|
import { spawn, spawnSync } from 'node:child_process'
|
|
5
|
+
// Resolved, never bare. `codex` on PATH is a shell alias to /Applications/Codex.app,
|
|
6
|
+
// which does not exist; the real binary is in ChatGPT.app. A bare argv works here only
|
|
7
|
+
// because COS Control injects that directory into the managed plist PATH -- it is ENOENT
|
|
8
|
+
// for every public npx user, and for anything launchd- or Finder-spawned.
|
|
9
|
+
import { resolveProviderBinary } from './provider-binary.js'
|
|
5
10
|
import { logTokenAudit } from './token-audit.js'
|
|
6
11
|
import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
|
|
7
12
|
import { buildSystemPrompt, buildLightweightSystemPrompt } from './context-builder.js'
|
|
@@ -137,7 +142,14 @@ let addDirSupported: boolean | undefined
|
|
|
137
142
|
export function codexSupportsAdditionalDir(): boolean {
|
|
138
143
|
if (addDirSupported !== undefined) return addDirSupported
|
|
139
144
|
try {
|
|
140
|
-
const
|
|
145
|
+
const resolved = resolveProviderBinary('codex')
|
|
146
|
+
if (!resolved.ok) {
|
|
147
|
+
// Cannot probe what cannot be found. Report unsupported rather than throwing:
|
|
148
|
+
// this gate only disables output publishing, and chat stays functional.
|
|
149
|
+
addDirSupported = false
|
|
150
|
+
return addDirSupported
|
|
151
|
+
}
|
|
152
|
+
const result = spawnSync(resolved.path, ['exec', '--help'], {
|
|
141
153
|
encoding: 'utf8',
|
|
142
154
|
timeout: 5_000,
|
|
143
155
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -413,7 +425,18 @@ export async function callCodexStreaming(
|
|
|
413
425
|
delete env.CLAUDECODE
|
|
414
426
|
if (outputImagePublisher) Object.assign(env, outputImagePublisher.env)
|
|
415
427
|
|
|
416
|
-
|
|
428
|
+
// The live turn path. This one REFUSES loudly rather than degrading -- a turn that
|
|
429
|
+
// cannot reach the binary must say so, not fail later as an opaque ENOENT from inside
|
|
430
|
+
// a spawn whose argv the caller never sees.
|
|
431
|
+
const resolvedCodex = resolveProviderBinary('codex')
|
|
432
|
+
if (!resolvedCodex.ok) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
`codex binary unresolved (${resolvedCodex.detail}). Checked the env override, `
|
|
435
|
+
+ 'then ChatGPT.app, then PATH; /Applications/Codex.app is excluded as a stale shim.',
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const proc = spawn(resolvedCodex.path, args, {
|
|
417
440
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
418
441
|
env,
|
|
419
442
|
cwd: codexCwd,
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
// the CLI default is used only when no discovered catalog exists yet.
|
|
7
7
|
|
|
8
8
|
import { spawn } from 'node:child_process'
|
|
9
|
+
// Resolved, never bare -- see provider-binary.ts. This module is a leaf, and the resolver
|
|
10
|
+
// was extracted precisely so importing it here cannot close the cycle
|
|
11
|
+
// catalog -> adapter -> codex-run-ledger -> catalog.
|
|
12
|
+
import { resolveProviderBinary } from './provider-binary.js'
|
|
9
13
|
import { existsSync, readFileSync } from 'node:fs'
|
|
10
14
|
import { homedir } from 'node:os'
|
|
11
15
|
import { resolve } from 'node:path'
|
|
@@ -248,7 +252,15 @@ async function fetchAppServerModels(): Promise<CodexCatalogModel[]> {
|
|
|
248
252
|
return new Promise((resolveModels, reject) => {
|
|
249
253
|
const env = { ...process.env }
|
|
250
254
|
delete env.CLAUDECODE
|
|
251
|
-
const
|
|
255
|
+
const resolved = resolveProviderBinary('codex')
|
|
256
|
+
if (!resolved.ok) {
|
|
257
|
+
// Degrade, do not throw: this module already models exactly this outcome as
|
|
258
|
+
// `CodexCatalogSource = 'cli-default'`. A catalog refresh that cannot find the
|
|
259
|
+
// binary is a stale catalog, not a broken server.
|
|
260
|
+
reject(new Error(`codex binary unresolved (${resolved.detail})`))
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
const child = spawn(resolved.path, ['app-server', '--stdio'], {
|
|
252
264
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
253
265
|
env,
|
|
254
266
|
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process'
|
|
2
|
+
import { resolveProviderBinary } from './provider-binary.js'
|
|
2
3
|
import { PYTHON_BIN } from './python-bridge.js'
|
|
3
4
|
import {
|
|
4
5
|
getCursorModelCatalog,
|
|
@@ -116,7 +117,12 @@ async function probeClaude(): Promise<{ value: string; available: boolean }> {
|
|
|
116
117
|
|
|
117
118
|
async function probeCodex(): Promise<{ value: string; available: boolean }> {
|
|
118
119
|
try {
|
|
119
|
-
|
|
120
|
+
// Distinguish "cannot find the binary" from "found it and it errored" -- collapsing
|
|
121
|
+
// both to 'error' told every public npx user their Codex was broken when the real
|
|
122
|
+
// problem was a bare argv resolving through a PATH they do not have.
|
|
123
|
+
const resolved = resolveProviderBinary('codex')
|
|
124
|
+
if (!resolved.ok) return { value: `unresolved (${resolved.detail})`, available: false }
|
|
125
|
+
const result = await execute(resolved.path, ['--version'])
|
|
120
126
|
const combined = `${result.stdout}\n${result.stderr}`.trim()
|
|
121
127
|
const versionLine = combined.split(/\r?\n/).map(line => line.trim())
|
|
122
128
|
.find(line => /^codex(?:-cli)?\s+/i.test(line))
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a provider's executable actually is.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `attached-provider-adapter.ts` 2026-08-19, unchanged. It moved because
|
|
5
|
+
* three other modules spawn providers by BARE NAME and need this resolution, and importing
|
|
6
|
+
* the adapter to get it closes a cycle: adapter -> codex-run-ledger -> codex-model-catalog
|
|
7
|
+
* -> adapter. This module imports nothing from the repo, so it can never be in one.
|
|
8
|
+
*
|
|
9
|
+
* Bare `'codex'` in argv resolves through PATH, and a Finder- or launchd-spawned process
|
|
10
|
+
* gets a minimal one. On this machine the bare sites work ONLY because COS Control injects
|
|
11
|
+
* ChatGPT.app onto the managed plist PATH -- they are broken for every public npx user.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { accessSync, constants as fsConstants, statSync } from 'node:fs'
|
|
15
|
+
import { delimiter, isAbsolute, join } from 'node:path'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Binary resolution
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export type BinaryResolutionFailure = 'env_override_unusable' | 'not_found'
|
|
23
|
+
|
|
24
|
+
export type BinaryResolution =
|
|
25
|
+
| { ok: true; path: string; source: 'env' | 'absolute' | 'path' }
|
|
26
|
+
| { ok: false; binary: string; detail: BinaryResolutionFailure }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Path prefixes that can never be a usable provider binary.
|
|
30
|
+
*
|
|
31
|
+
* `codex` resolved through PATH (or a shell alias) points at
|
|
32
|
+
* `/Applications/Codex.app/Contents/Resources/codex` on this machine, and that
|
|
33
|
+
* app no longer exists. Today the shim is dangling, so an existence check
|
|
34
|
+
* already rejects it — but a reinstalled or partially-removed Codex.app puts a
|
|
35
|
+
* real, executable file back on that path, and then only this list stands
|
|
36
|
+
* between an attached turn and a binary that cannot serve it.
|
|
37
|
+
*/
|
|
38
|
+
export const STALE_SHIM_PREFIXES: readonly string[] = ['/Applications/Codex.app/']
|
|
39
|
+
|
|
40
|
+
export function isKnownStaleShimPath(
|
|
41
|
+
candidate: string,
|
|
42
|
+
prefixes: readonly string[] = STALE_SHIM_PREFIXES,
|
|
43
|
+
): boolean {
|
|
44
|
+
// A non-array argument falls back to the known list rather than to "exclude
|
|
45
|
+
// nothing" — the classic version of this bug is `list.some(isKnownStaleShimPath)`,
|
|
46
|
+
// where `.some` passes the INDEX as the second argument and every exclusion
|
|
47
|
+
// silently disappears.
|
|
48
|
+
const list = Array.isArray(prefixes) ? prefixes : STALE_SHIM_PREFIXES
|
|
49
|
+
return list.some(prefix => typeof prefix === 'string' && prefix.length > 0 && candidate.startsWith(prefix))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isUsableExecutable(
|
|
53
|
+
candidate: string,
|
|
54
|
+
excludePrefixes: readonly string[] = STALE_SHIM_PREFIXES,
|
|
55
|
+
): boolean {
|
|
56
|
+
try {
|
|
57
|
+
if (!isAbsolute(candidate) || candidate.includes('\0')) return false
|
|
58
|
+
if (isKnownStaleShimPath(candidate, excludePrefixes)) return false
|
|
59
|
+
if (!statSync(candidate).isFile()) return false
|
|
60
|
+
accessSync(candidate, fsConstants.X_OK)
|
|
61
|
+
return true
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface BinarySpec {
|
|
68
|
+
name: string
|
|
69
|
+
envKeys: readonly string[]
|
|
70
|
+
/** Tried in order, BEFORE any PATH scan. */
|
|
71
|
+
absolutes: readonly string[]
|
|
72
|
+
/**
|
|
73
|
+
* Paths that must never be selected, from any source. Defaults to
|
|
74
|
+
* `STALE_SHIM_PREFIXES`.
|
|
75
|
+
*
|
|
76
|
+
* On the spec rather than hardcoded because the exclusion is the only guard
|
|
77
|
+
* standing between resolution and a known-bad binary, and a guard whose input
|
|
78
|
+
* cannot be constructed is a guard nobody can prove works: the real prefix
|
|
79
|
+
* lives under `/Applications`, which no fixture can write to.
|
|
80
|
+
*/
|
|
81
|
+
excludePrefixes?: readonly string[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Where each provider's binary is looked for, in precedence order.
|
|
86
|
+
*
|
|
87
|
+
* Exported as data rather than kept private so the precedence itself is
|
|
88
|
+
* testable: on a machine where a stale shim sits on PATH, "ChatGPT.app is tried
|
|
89
|
+
* before PATH" is the property that decides whether an attached Codex turn
|
|
90
|
+
* launches the real binary or a dangling one.
|
|
91
|
+
*/
|
|
92
|
+
/**
|
|
93
|
+
* Takes a plain string, not `AttachedProvider`, deliberately. That type is declared in
|
|
94
|
+
* `attached-provider-adapter.ts`, and importing it back would recreate exactly the cycle
|
|
95
|
+
* this extraction exists to break. Callers pass a string-union value, which is assignable.
|
|
96
|
+
*/
|
|
97
|
+
export function providerBinarySpec(provider: string): BinarySpec {
|
|
98
|
+
const home = (() => {
|
|
99
|
+
try {
|
|
100
|
+
return homedir()
|
|
101
|
+
} catch {
|
|
102
|
+
return ''
|
|
103
|
+
}
|
|
104
|
+
})()
|
|
105
|
+
if (provider === 'claude') {
|
|
106
|
+
return {
|
|
107
|
+
name: 'claude',
|
|
108
|
+
envKeys: ['COS_ATTACHED_CLAUDE_BIN', 'COS_CLAUDE_BIN'],
|
|
109
|
+
absolutes: [
|
|
110
|
+
'/opt/homebrew/bin/claude',
|
|
111
|
+
'/usr/local/bin/claude',
|
|
112
|
+
home ? join(home, '.local', 'bin', 'claude') : '',
|
|
113
|
+
].filter(Boolean),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
name: 'codex',
|
|
118
|
+
envKeys: ['COS_ATTACHED_CODEX_BIN', 'COS_CODEX_BIN'],
|
|
119
|
+
absolutes: [
|
|
120
|
+
// Verified 2026-08-15: codex-cli 0.148.0-alpha.9 lives here, and there is
|
|
121
|
+
// no `codex` on PATH at all on this machine.
|
|
122
|
+
'/Applications/ChatGPT.app/Contents/Resources/codex',
|
|
123
|
+
home ? join(home, '.codex', 'bin', 'codex') : '',
|
|
124
|
+
'/opt/homebrew/bin/codex',
|
|
125
|
+
'/usr/local/bin/codex',
|
|
126
|
+
].filter(Boolean),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a provider binary to a verified absolute path, or refuse.
|
|
132
|
+
*
|
|
133
|
+
* Never returns a bare name. A bare name is a silent PATH lookup, and the two
|
|
134
|
+
* environments this server runs in disagree about PATH: a login shell finds the
|
|
135
|
+
* CLI, a Finder- or launchd-spawned process gets a minimal PATH and does not
|
|
136
|
+
* (hit twice in COS Control). The PATH scan below is done by us, entry by
|
|
137
|
+
* entry, and still yields an absolute path we have stat'ed — so a failure names
|
|
138
|
+
* the missing binary instead of surfacing as ENOENT from inside a spawn.
|
|
139
|
+
*
|
|
140
|
+
* An unusable env override REFUSES rather than falling through to the
|
|
141
|
+
* candidates: an operator who set it wrongly needs to be told, not overridden.
|
|
142
|
+
*/
|
|
143
|
+
export function resolveProviderBinary(
|
|
144
|
+
provider: string,
|
|
145
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
146
|
+
): BinaryResolution {
|
|
147
|
+
return resolveBinaryFromSpec(providerBinarySpec(provider), env)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The resolution algorithm itself, separated from the provider tables.
|
|
152
|
+
*
|
|
153
|
+
* Not a testing seam bolted on: on any developer machine at least one real
|
|
154
|
+
* absolute candidate exists, so the PATH-scan and not-found branches of
|
|
155
|
+
* `resolveProviderBinary` are unreachable from a test and would ship
|
|
156
|
+
* unexercised — which is exactly how a launchd-only failure hides. Driving the
|
|
157
|
+
* REAL algorithm with a fixture spec exercises them for real.
|
|
158
|
+
*/
|
|
159
|
+
export function resolveBinaryFromSpec(spec: BinarySpec, env: NodeJS.ProcessEnv): BinaryResolution {
|
|
160
|
+
const excluded = spec.excludePrefixes ?? STALE_SHIM_PREFIXES
|
|
161
|
+
|
|
162
|
+
for (const key of spec.envKeys) {
|
|
163
|
+
const raw = env[key]
|
|
164
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) continue
|
|
165
|
+
const candidate = raw.trim()
|
|
166
|
+
if (!isUsableExecutable(candidate, excluded)) {
|
|
167
|
+
return { ok: false, binary: spec.name, detail: 'env_override_unusable' }
|
|
168
|
+
}
|
|
169
|
+
return { ok: true, path: candidate, source: 'env' }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const candidate of spec.absolutes) {
|
|
173
|
+
if (isUsableExecutable(candidate, excluded)) return { ok: true, path: candidate, source: 'absolute' }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const pathValue = typeof env.PATH === 'string' ? env.PATH : ''
|
|
177
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
178
|
+
// A relative PATH entry resolves against the server's cwd, which is not a
|
|
179
|
+
// location we control. Skipped rather than resolved.
|
|
180
|
+
//
|
|
181
|
+
// Redundant with the `isAbsolute` inside `isUsableExecutable` — verified by
|
|
182
|
+
// mutation: removing EITHER one alone changes no outcome, and only removing
|
|
183
|
+
// BOTH lets a relative entry through. Kept because two independent guards
|
|
184
|
+
// on "never resolve against the server cwd" is the correct amount for a
|
|
185
|
+
// path that ends up as a spawned executable.
|
|
186
|
+
if (!dir || !isAbsolute(dir)) continue
|
|
187
|
+
const candidate = join(dir, spec.name)
|
|
188
|
+
if (isUsableExecutable(candidate, excluded)) return { ok: true, path: candidate, source: 'path' }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { ok: false, binary: spec.name, detail: 'not_found' }
|
|
192
|
+
}
|