@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.12",
3
+ "version": "6.36.13",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 result = spawnSync('codex', ['exec', '--help'], {
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
- const proc = spawn('codex', args, {
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 child = spawn('codex', ['app-server', '--stdio'], {
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
- const result = await execute('codex', ['--version'])
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
+ }