agentxchain 2.130.0 → 2.130.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.130.0",
3
+ "version": "2.130.1",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,8 @@
24
24
  "dev": "node bin/agentxchain.js",
25
25
  "test": "npm run test:vitest && npm run test:node",
26
26
  "test:vitest": "vitest run --reporter=verbose",
27
- "test:node": "node --test test/*.test.js",
27
+ "test:beta": "node --test test/beta-tester-scenarios/*.test.js",
28
+ "test:node": "node --test test/*.test.js test/beta-tester-scenarios/*.test.js",
28
29
  "preflight:release": "bash scripts/release-preflight.sh",
29
30
  "preflight:release:strict": "bash scripts/release-preflight.sh --strict",
30
31
  "check:release-alignment": "node scripts/check-release-alignment.mjs",
@@ -110,24 +110,37 @@ if [[ "$PUBLISH_GATE" -eq 1 ]]; then
110
110
  echo "[3/7] Release-gate tests (targeted subset)"
111
111
  # In publish-gate mode, run only release-critical tests to avoid CI hangs.
112
112
  # The full test suite is a pre-tag responsibility, not a publish-time gate.
113
- GATE_TESTS=(
113
+ GATE_TEST_PATTERNS=(
114
114
  test/release-preflight.test.js
115
115
  test/release-docs-content.test.js
116
116
  test/release-notes-gate.test.js
117
117
  test/release-identity-hardening.test.js
118
118
  test/normalized-config.test.js
119
119
  test/conformance.test.js
120
+ test/beta-tester-scenarios/*.test.js
120
121
  )
121
122
  GATE_TEST_ARGS=()
122
- for t in "${GATE_TESTS[@]}"; do
123
- if [[ -f "$t" ]]; then
123
+ shopt -s nullglob
124
+ for pattern in "${GATE_TEST_PATTERNS[@]}"; do
125
+ for t in $pattern; do
124
126
  GATE_TEST_ARGS+=("$t")
125
- fi
127
+ done
126
128
  done
129
+ shopt -u nullglob
127
130
  if [[ ${#GATE_TEST_ARGS[@]} -eq 0 ]]; then
128
131
  fail "No release-gate test files found"
129
132
  else
130
- if run_and_capture TEST_OUTPUT env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 node --test "${GATE_TEST_ARGS[@]}"; then
133
+ BETA_TEST_COUNT=0
134
+ for t in "${GATE_TEST_ARGS[@]}"; do
135
+ if [[ "$t" == test/beta-tester-scenarios/*.test.js ]]; then
136
+ BETA_TEST_COUNT=$((BETA_TEST_COUNT + 1))
137
+ fi
138
+ done
139
+ if [[ "$BETA_TEST_COUNT" -eq 0 ]]; then
140
+ fail "No beta-tester scenario tests found for release-gate verification"
141
+ TEST_OUTPUT=""
142
+ TEST_STATUS=1
143
+ elif run_and_capture TEST_OUTPUT env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 node --test "${GATE_TEST_ARGS[@]}"; then
131
144
  TEST_STATUS=0
132
145
  else
133
146
  TEST_STATUS=$?
@@ -105,6 +105,7 @@ export async function connectorCheckCommand(runtimeId, options = {}) {
105
105
  const result = await probeConfiguredConnectors(context.config, {
106
106
  runtimeId: runtimeId || null,
107
107
  timeoutMs,
108
+ root: context.root,
108
109
  onProbeStart: options.json ? null : (probeRuntimeId, runtime) => {
109
110
  console.log(` ${chalk.dim('…')} Probing ${chalk.bold(probeRuntimeId)} ${chalk.dim(`(${runtime.type})`)}`);
110
111
  },
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
- import { execFileSync, execSync } from 'child_process';
2
+ import { execFileSync } from 'child_process';
3
3
  import { join } from 'path';
4
4
  import chalk from 'chalk';
5
5
  import { loadConfig, loadLock, findProjectRoot, loadProjectState } from '../lib/config.js';
@@ -20,6 +20,7 @@ import {
20
20
  import { detectActiveTurnBindingDrift, detectStateBundleDesync } from '../lib/governed-state.js';
21
21
  import { findPendingApprovedIntents } from '../lib/intake.js';
22
22
  import { checkCleanBaseline } from '../lib/repo-observer.js';
23
+ import { probeRuntimeSpawnContext } from '../lib/runtime-spawn-context.js';
23
24
 
24
25
  export async function doctorCommand(opts = {}) {
25
26
  const root = findProjectRoot(process.cwd());
@@ -90,7 +91,7 @@ function governedDoctor(root, rawConfig, opts) {
90
91
  const runtimes = (normalized && normalized.runtimes) || rawConfig.runtimes || {};
91
92
  const rolesByRuntime = buildRolesByRuntime(normalized?.roles || {});
92
93
  for (const [rtId, rt] of Object.entries(runtimes)) {
93
- const check = checkRuntimeReachable(rtId, rt, rolesByRuntime[rtId] || []);
94
+ const check = checkRuntimeReachable(root, rtId, rt, rolesByRuntime[rtId] || []);
94
95
  checks.push(check);
95
96
  }
96
97
  const connectorProbe = getConnectorProbeRecommendation(runtimes);
@@ -484,7 +485,7 @@ function buildCliVersionCheck(cliVersionHealth) {
484
485
  };
485
486
  }
486
487
 
487
- function checkRuntimeReachable(rtId, rt, boundRoleEntries = []) {
488
+ function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
488
489
  const base = { id: `runtime_${rtId}`, name: `Runtime: ${rtId}` };
489
490
 
490
491
  if (!rt || !rt.type) {
@@ -496,14 +497,8 @@ function checkRuntimeReachable(rtId, rt, boundRoleEntries = []) {
496
497
  return attachRuntimeContract({ ...base, level: 'pass', detail: 'Manual runtime (no binary needed)' }, rtId, rt, boundRoleEntries);
497
498
 
498
499
  case 'local_cli': {
499
- const cmd = Array.isArray(rt.command) ? rt.command[0] : (typeof rt.command === 'string' ? rt.command.split(/\s+/)[0] : null);
500
- if (!cmd) return attachRuntimeContract({ ...base, level: 'warn', detail: 'No command configured' }, rtId, rt, boundRoleEntries);
501
- try {
502
- execSync(`command -v ${cmd}`, { stdio: 'ignore' });
503
- return attachRuntimeContract({ ...base, level: 'pass', detail: `${cmd} binary found` }, rtId, rt, boundRoleEntries);
504
- } catch {
505
- return attachRuntimeContract({ ...base, level: 'fail', detail: `${cmd} not found in PATH` }, rtId, rt, boundRoleEntries);
506
- }
500
+ const probe = probeRuntimeSpawnContext(root, rt, { runtimeId: rtId });
501
+ return attachRuntimeContract({ ...base, level: probe.ok ? 'pass' : 'fail', detail: probe.detail }, rtId, rt, boundRoleEntries);
507
502
  }
508
503
 
509
504
  case 'api_proxy': {
@@ -523,14 +518,8 @@ function checkRuntimeReachable(rtId, rt, boundRoleEntries = []) {
523
518
  if (transport === 'streamable_http') {
524
519
  return attachRuntimeContract({ ...base, level: 'warn', detail: 'Remote MCP endpoint (cannot verify at doctor time)' }, rtId, rt, boundRoleEntries);
525
520
  }
526
- const cmd = Array.isArray(rt.command) ? rt.command[0] : (typeof rt.command === 'string' ? rt.command.split(/\s+/)[0] : null);
527
- if (!cmd) return attachRuntimeContract({ ...base, level: 'warn', detail: 'No MCP command configured' }, rtId, rt, boundRoleEntries);
528
- try {
529
- execSync(`command -v ${cmd}`, { stdio: 'ignore' });
530
- return attachRuntimeContract({ ...base, level: 'pass', detail: `${cmd} binary found` }, rtId, rt, boundRoleEntries);
531
- } catch {
532
- return attachRuntimeContract({ ...base, level: 'fail', detail: `${cmd} not found in PATH` }, rtId, rt, boundRoleEntries);
533
- }
521
+ const probe = probeRuntimeSpawnContext(root, rt, { runtimeId: rtId });
522
+ return attachRuntimeContract({ ...base, level: probe.ok ? 'pass' : 'fail', detail: probe.detail }, rtId, rt, boundRoleEntries);
534
523
  }
535
524
 
536
525
  case 'remote_agent':
@@ -1,10 +1,9 @@
1
- import { execFileSync } from 'node:child_process';
2
-
3
1
  import {
4
2
  buildProviderHeaders,
5
3
  buildProviderRequest,
6
4
  PROVIDER_ENDPOINTS,
7
5
  } from './adapters/api-proxy-adapter.js';
6
+ import { probeRuntimeSpawnContext } from './runtime-spawn-context.js';
8
7
 
9
8
  const PROBEABLE_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
10
9
  const DEFAULT_TIMEOUT_MS = 8_000;
@@ -35,8 +34,8 @@ const KNOWN_CLI_AUTHORITY_FLAGS = [
35
34
  * Maps binary name to expected transport.
36
35
  */
37
36
  const KNOWN_CLI_TRANSPORTS = {
38
- claude: 'stdin',
39
- codex: 'argv',
37
+ claude: ['stdin'],
38
+ codex: ['argv', 'stdin'],
40
39
  };
41
40
 
42
41
  function formatCommand(command, args = []) {
@@ -78,11 +77,6 @@ function commandHead(runtime) {
78
77
  return null;
79
78
  }
80
79
 
81
- function resolveBinary(command) {
82
- const resolver = process.platform === 'win32' ? 'where' : 'which';
83
- execFileSync(resolver, [command], { stdio: 'ignore' });
84
- }
85
-
86
80
  function resolveProviderEndpoint(runtime) {
87
81
  if (typeof runtime?.base_url === 'string' && runtime.base_url.trim()) {
88
82
  return runtime.base_url.trim();
@@ -153,7 +147,7 @@ async function probeHttp({ url, method = 'GET', headers = {}, body, timeoutMs })
153
147
  }
154
148
  }
155
149
 
156
- async function probeLocalCommand(runtimeId, runtime, probeKindLabel) {
150
+ async function probeLocalCommand(runtimeId, runtime, probeKindLabel, options = {}) {
157
151
  const head = commandHead(runtime);
158
152
  const base = {
159
153
  runtime_id: runtimeId,
@@ -170,22 +164,22 @@ async function probeLocalCommand(runtimeId, runtime, probeKindLabel) {
170
164
  };
171
165
  }
172
166
 
173
- try {
174
- resolveBinary(head);
167
+ const spawnProbe = probeRuntimeSpawnContext(options.root || process.cwd(), runtime, { runtimeId });
168
+ if (spawnProbe.ok) {
175
169
  return {
176
170
  ...base,
177
171
  level: 'pass',
178
- command: head,
179
- detail: `${head} is available on PATH`,
180
- };
181
- } catch {
182
- return {
183
- ...base,
184
- level: 'fail',
185
- command: head,
186
- detail: `${head} was not found on PATH`,
172
+ command: spawnProbe.command || head,
173
+ detail: spawnProbe.detail,
187
174
  };
188
175
  }
176
+
177
+ return {
178
+ ...base,
179
+ level: 'fail',
180
+ command: spawnProbe.command || head,
181
+ detail: spawnProbe.detail,
182
+ };
189
183
  }
190
184
 
191
185
  async function probeApiProxy(runtimeId, runtime, timeoutMs) {
@@ -331,6 +325,27 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
331
325
  if (boundRoles.length === 0) return { warnings };
332
326
 
333
327
  const authoritativeRoles = boundRoles.filter((r) => r.write_authority === 'authoritative');
328
+ const isCodex = binaryName === 'codex' || binaryName.endsWith('/codex');
329
+
330
+ if (isCodex) {
331
+ if (commandTokens[1] !== 'exec') {
332
+ warnings.push({
333
+ probe_kind: 'command_intent',
334
+ level: 'warn',
335
+ detail: 'OpenAI Codex CLI governed local runs should use the non-interactive "exec" subcommand. Top-level "codex" is the interactive entrypoint.',
336
+ fix: 'Use ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", "{prompt}"]',
337
+ });
338
+ }
339
+
340
+ if (commandTokens.includes('--quiet')) {
341
+ warnings.push({
342
+ probe_kind: 'command_intent',
343
+ level: 'warn',
344
+ detail: 'OpenAI Codex CLI rejects "--quiet" in governed local_cli commands on the current CLI. The command exits before the turn starts.',
345
+ fix: 'Remove "--quiet" and use ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", "{prompt}"]',
346
+ });
347
+ }
348
+ }
334
349
 
335
350
  // Check known CLI authority flags
336
351
  const knownCli = KNOWN_CLI_AUTHORITY_FLAGS.find((entry) => binaryName === entry.binary || binaryName.endsWith(`/${entry.binary}`));
@@ -359,7 +374,7 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
359
374
 
360
375
  // Prompt transport validation
361
376
  const transport = runtime.prompt_transport || 'dispatch_bundle_only';
362
- const knownTransport = KNOWN_CLI_TRANSPORTS[binaryName];
377
+ const knownTransports = KNOWN_CLI_TRANSPORTS[binaryName];
363
378
 
364
379
  if (transport === 'argv' && !commandTokens.some((token) => token.includes('{prompt}'))) {
365
380
  warnings.push({
@@ -370,13 +385,13 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
370
385
  });
371
386
  }
372
387
 
373
- if (knownTransport && transport !== knownTransport && transport !== 'dispatch_bundle_only') {
388
+ if (knownTransports && !knownTransports.includes(transport) && transport !== 'dispatch_bundle_only') {
374
389
  const transportLabel = knownCli ? knownCli.label : binaryName;
375
390
  warnings.push({
376
391
  probe_kind: 'transport_intent',
377
392
  level: 'warn',
378
- detail: `${transportLabel} typically uses "${knownTransport}" transport, but this runtime is configured with "${transport}"`,
379
- fix: `Set prompt_transport to "${knownTransport}" or "dispatch_bundle_only"`,
393
+ detail: `${transportLabel} typically uses ${knownTransports.map((value) => `"${value}"`).join(' or ')} transport, but this runtime is configured with "${transport}"`,
394
+ fix: `Set prompt_transport to ${knownTransports.map((value) => `"${value}"`).join(' or ')} or "dispatch_bundle_only"`,
380
395
  });
381
396
  }
382
397
 
@@ -414,7 +429,7 @@ export async function probeConnectorRuntime(runtimeId, runtime, options = {}) {
414
429
  }
415
430
 
416
431
  if (runtime.type === 'local_cli') {
417
- const result = await probeLocalCommand(runtimeId, runtime, 'command_presence');
432
+ const result = await probeLocalCommand(runtimeId, runtime, 'command_presence', options);
418
433
  // Add authority-intent and transport analysis when roles are available
419
434
  if (roles) {
420
435
  const { warnings } = analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles);
@@ -437,7 +452,7 @@ export async function probeConnectorRuntime(runtimeId, runtime, options = {}) {
437
452
  if (runtime.transport === 'streamable_http') {
438
453
  return probeHttpRuntime(runtimeId, runtime, timeoutMs);
439
454
  }
440
- return probeLocalCommand(runtimeId, runtime, 'command_presence');
455
+ return probeLocalCommand(runtimeId, runtime, 'command_presence', options);
441
456
  }
442
457
 
443
458
  return probeHttpRuntime(runtimeId, runtime, timeoutMs);
@@ -22,6 +22,7 @@ import { dispatchMcp } from './adapters/mcp-adapter.js';
22
22
  import { dispatchRemoteAgent } from './adapters/remote-agent-adapter.js';
23
23
  import { getDispatchPromptPath, getTurnStagingResultPath } from './turn-paths.js';
24
24
  import { validateStagedTurnResult } from './turn-result-validator.js';
25
+ import { probeRuntimeSpawnContext } from './runtime-spawn-context.js';
25
26
 
26
27
  const VALIDATABLE_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
27
28
  const DEFAULT_VALIDATE_TIMEOUT_MS = 120_000;
@@ -130,6 +131,26 @@ export async function validateConfiguredConnector(sourceRoot, options = {}) {
130
131
  };
131
132
  }
132
133
 
134
+ if (runtime.type === 'local_cli' || (runtime.type === 'mcp' && (runtime.transport || 'stdio') !== 'streamable_http')) {
135
+ const spawnProbe = probeRuntimeSpawnContext(scratchRoot, scratchContext.config.runtimes[runtimeId], { runtimeId });
136
+ if (!spawnProbe.ok) {
137
+ return {
138
+ ok: false,
139
+ exitCode: 1,
140
+ overall: 'fail',
141
+ runtime_id: runtimeId,
142
+ runtime_type: runtime.type,
143
+ role_id: roleSelection.roleId,
144
+ timeout_ms: timeoutMs,
145
+ warnings,
146
+ dispatch: null,
147
+ validation: null,
148
+ error: spawnProbe.detail,
149
+ scratch_root: scratchRoot,
150
+ };
151
+ }
152
+ }
153
+
133
154
  const initResult = initializeGovernedRun(scratchRoot, scratchContext.config, {
134
155
  provenance: {
135
156
  trigger: 'connector_validate',
@@ -2488,7 +2488,8 @@ export function reissueTurn(root, config, opts = {}) {
2488
2488
  const oldRuntimeId = oldTurn.runtime_id;
2489
2489
 
2490
2490
  // Resolve current runtime binding (may have changed in config)
2491
- const currentRuntimeId = role.runtime;
2491
+ // BUG-25 fix: normalized config uses runtime_id, raw config uses runtime
2492
+ const currentRuntimeId = role.runtime_id || role.runtime;
2492
2493
  const currentRuntime = config.runtimes?.[currentRuntimeId];
2493
2494
  if (!currentRuntime) {
2494
2495
  return { ok: false, error: `Runtime "${currentRuntimeId}" not found in config for role "${roleId}"` };
@@ -0,0 +1,163 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+
5
+ const DEFAULT_PROBE_TIMEOUT_MS = 500;
6
+ const PROMPT_PLACEHOLDER = 'AgentXchain spawn-context probe';
7
+
8
+ function resolveLocalCliPromptTransport(runtime) {
9
+ const valid = new Set(['argv', 'stdin', 'dispatch_bundle_only']);
10
+ if (valid.has(runtime?.prompt_transport)) {
11
+ return runtime.prompt_transport;
12
+ }
13
+
14
+ const parts = Array.isArray(runtime?.command)
15
+ ? runtime.command
16
+ : [runtime?.command, ...(Array.isArray(runtime?.args) ? runtime.args : [])];
17
+ const hasPrompt = parts.some((part) => typeof part === 'string' && part.includes('{prompt}'));
18
+ return hasPrompt ? 'argv' : 'dispatch_bundle_only';
19
+ }
20
+
21
+ function resolveLocalCliInvocation(runtime) {
22
+ if (!runtime?.command) {
23
+ return { command: null, args: [] };
24
+ }
25
+
26
+ const transport = resolveLocalCliPromptTransport(runtime);
27
+
28
+ if (Array.isArray(runtime.command)) {
29
+ const first = runtime.command[0] || '';
30
+ const headParts = typeof first === 'string' && first.includes(' ') ? first.split(/\s+/) : [first];
31
+ const [command, ...headArgs] = headParts;
32
+ const rest = [...headArgs, ...runtime.command.slice(1)];
33
+ const args = transport === 'argv'
34
+ ? rest.map((arg) => arg === '{prompt}' ? PROMPT_PLACEHOLDER : arg)
35
+ : rest.filter((arg) => arg !== '{prompt}');
36
+ return { command, args };
37
+ }
38
+
39
+ const args = transport === 'argv'
40
+ ? (runtime.args || []).map((arg) => arg === '{prompt}' ? PROMPT_PLACEHOLDER : arg)
41
+ : (runtime.args || []).filter((arg) => arg !== '{prompt}');
42
+ return { command: runtime.command, args };
43
+ }
44
+
45
+ function resolveMcpInvocation(runtime) {
46
+ if (!runtime?.command) {
47
+ return { command: null, args: [] };
48
+ }
49
+
50
+ if (Array.isArray(runtime.command)) {
51
+ const [command, ...args] = runtime.command;
52
+ return { command, args };
53
+ }
54
+
55
+ return {
56
+ command: runtime.command,
57
+ args: Array.isArray(runtime.args) ? runtime.args : [],
58
+ };
59
+ }
60
+
61
+ function resolveInvocation(runtime) {
62
+ if (runtime?.type === 'local_cli') {
63
+ return resolveLocalCliInvocation(runtime);
64
+ }
65
+ return resolveMcpInvocation(runtime);
66
+ }
67
+
68
+ function buildResolutionFix(command) {
69
+ const commandValue = String(command || '');
70
+ const commandBase = basename(commandValue);
71
+
72
+ if (commandBase === 'codex' || commandBase === 'codex.exe') {
73
+ return 'Set "command" to the absolute path, e.g. "/Applications/Codex.app/Contents/Resources/codex", or add Codex to PATH in the dispatch spawn context.';
74
+ }
75
+ if (commandValue.includes('~')) {
76
+ return 'Expand "~" to an absolute path in "command". Shell expansion does not apply to governed dispatch.';
77
+ }
78
+ return 'Set "command" to an absolute path or add it to PATH in the dispatch spawn context.';
79
+ }
80
+
81
+ export function probeRuntimeSpawnContext(root, runtime, options = {}) {
82
+ const runtimeId = options.runtimeId || null;
83
+ const cwd = runtime?.cwd ? join(root, runtime.cwd) : root;
84
+ const { command, args } = resolveInvocation(runtime);
85
+
86
+ if (!command) {
87
+ return {
88
+ ok: false,
89
+ runtime_id: runtimeId,
90
+ command: null,
91
+ cwd,
92
+ detail: 'No command configured for the dispatch spawn context.',
93
+ };
94
+ }
95
+
96
+ if (!existsSync(cwd)) {
97
+ return {
98
+ ok: false,
99
+ runtime_id: runtimeId,
100
+ command,
101
+ cwd,
102
+ detail: `Runtime cwd "${runtime.cwd}" does not exist in the dispatch spawn context.`,
103
+ };
104
+ }
105
+
106
+ const probe = spawnSync(command, args, {
107
+ cwd,
108
+ env: { ...process.env, AGENTXCHAIN_SPAWN_PROBE: '1' },
109
+ stdio: 'ignore',
110
+ timeout: options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
111
+ windowsHide: true,
112
+ });
113
+
114
+ if (probe.error) {
115
+ const errorCode = probe.error.code || 'spawn_error';
116
+ if (errorCode === 'ETIMEDOUT') {
117
+ return {
118
+ ok: true,
119
+ runtime_id: runtimeId,
120
+ command,
121
+ cwd,
122
+ timed_out: true,
123
+ detail: `"${command}" launched in the dispatch spawn context but exceeded the short probe timeout. Treating this as resolvable.`,
124
+ };
125
+ }
126
+ if (errorCode === 'ENOENT') {
127
+ return {
128
+ ok: false,
129
+ runtime_id: runtimeId,
130
+ command,
131
+ cwd,
132
+ error_code: errorCode,
133
+ detail: `"${command}" is not resolvable in the dispatch spawn context. ${buildResolutionFix(command)}`,
134
+ };
135
+ }
136
+ if (errorCode === 'EACCES') {
137
+ return {
138
+ ok: false,
139
+ runtime_id: runtimeId,
140
+ command,
141
+ cwd,
142
+ error_code: errorCode,
143
+ detail: `"${command}" exists but is not executable in the dispatch spawn context. Mark it executable or point "command" at the real executable path.`,
144
+ };
145
+ }
146
+ return {
147
+ ok: false,
148
+ runtime_id: runtimeId,
149
+ command,
150
+ cwd,
151
+ error_code: errorCode,
152
+ detail: `Dispatch spawn probe failed for "${command}": ${probe.error.message}`,
153
+ };
154
+ }
155
+
156
+ return {
157
+ ok: true,
158
+ runtime_id: runtimeId,
159
+ command,
160
+ cwd,
161
+ detail: `"${command}" is resolvable in the dispatch spawn context.`,
162
+ };
163
+ }