agentxchain 2.148.0 → 2.149.2

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.
@@ -5,6 +5,17 @@ import { DEFAULT_VALIDATE_TIMEOUT_MS, validateConfiguredConnector } from '../lib
5
5
  import { DEFAULT_TIMEOUT_MS, probeConfiguredConnectors } from '../lib/connector-probe.js';
6
6
  import { buildRuntimeCapabilityReport } from '../lib/runtime-capabilities.js';
7
7
 
8
+ function warningDetail(warning) {
9
+ if (typeof warning === 'string') {
10
+ return warning;
11
+ }
12
+ return warning?.detail || JSON.stringify(warning);
13
+ }
14
+
15
+ function warningFix(warning) {
16
+ return typeof warning === 'object' && warning?.fix ? warning.fix : null;
17
+ }
18
+
8
19
  function printJson(result, exitCode) {
9
20
  console.log(JSON.stringify(result, null, 2));
10
21
  process.exit(exitCode);
@@ -49,11 +60,15 @@ function printText(result, exitCode) {
49
60
  console.log(` ${chalk.dim('Time:')} ${connector.latency_ms}ms`);
50
61
  }
51
62
  console.log(` ${chalk.dim('Detail:')} ${connector.detail}`);
63
+ if (connector.fix) {
64
+ console.log(` ${chalk.dim('Fix:')} ${connector.fix}`);
65
+ }
52
66
  if (Array.isArray(connector.authority_warnings) && connector.authority_warnings.length > 0) {
53
67
  for (const warning of connector.authority_warnings) {
54
- console.log(` ${chalk.yellow('⚠')} ${warning.detail}`);
55
- if (warning.fix) {
56
- console.log(` ${chalk.dim('Fix:')} ${warning.fix}`);
68
+ console.log(` ${chalk.yellow('⚠')} ${warningDetail(warning)}`);
69
+ const fix = warningFix(warning);
70
+ if (fix) {
71
+ console.log(` ${chalk.dim('Fix:')} ${fix}`);
57
72
  }
58
73
  }
59
74
  }
@@ -161,7 +176,11 @@ function printValidateText(result, exitCode) {
161
176
  if (Array.isArray(result.warnings) && result.warnings.length > 0) {
162
177
  console.log('');
163
178
  for (const warning of result.warnings) {
164
- console.log(` ${chalk.yellow('!')} ${warning}`);
179
+ console.log(` ${chalk.yellow('!')} ${warningDetail(warning)}`);
180
+ const fix = warningFix(warning);
181
+ if (fix) {
182
+ console.log(` ${chalk.dim('Fix:')} ${fix}`);
183
+ }
165
184
  }
166
185
  }
167
186
 
@@ -21,6 +21,7 @@ import { detectActiveTurnBindingDrift, detectStateBundleDesync } from '../lib/go
21
21
  import { findPendingApprovedIntents } from '../lib/intake.js';
22
22
  import { checkCleanBaseline } from '../lib/repo-observer.js';
23
23
  import { probeRuntimeSpawnContext } from '../lib/runtime-spawn-context.js';
24
+ import { getClaudeSubprocessAuthIssue } from '../lib/claude-local-auth.js';
24
25
 
25
26
  export async function doctorCommand(opts = {}) {
26
27
  const root = findProjectRoot(process.cwd());
@@ -58,7 +59,7 @@ export async function doctorCommand(opts = {}) {
58
59
 
59
60
  // ── Governed (v4) Doctor ────────────────────────────────────────────────────
60
61
 
61
- function governedDoctor(root, rawConfig, opts) {
62
+ async function governedDoctor(root, rawConfig, opts) {
62
63
  const checks = [];
63
64
  const cliVersionHealth = getCliVersionHealth();
64
65
  let stateRunId = null;
@@ -92,7 +93,7 @@ function governedDoctor(root, rawConfig, opts) {
92
93
  const runtimes = (normalized && normalized.runtimes) || rawConfig.runtimes || {};
93
94
  const rolesByRuntime = buildRolesByRuntime(normalized?.roles || {});
94
95
  for (const [rtId, rt] of Object.entries(runtimes)) {
95
- const check = checkRuntimeReachable(root, rtId, rt, rolesByRuntime[rtId] || []);
96
+ const check = await checkRuntimeReachable(root, rtId, rt, rolesByRuntime[rtId] || []);
96
97
  checks.push(check);
97
98
  }
98
99
  const connectorProbe = getConnectorProbeRecommendation(runtimes);
@@ -487,7 +488,7 @@ function buildCliVersionCheck(cliVersionHealth) {
487
488
  };
488
489
  }
489
490
 
490
- function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
491
+ async function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
491
492
  const base = { id: `runtime_${rtId}`, name: `Runtime: ${rtId}` };
492
493
 
493
494
  if (!rt || !rt.type) {
@@ -500,6 +501,16 @@ function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
500
501
 
501
502
  case 'local_cli': {
502
503
  const probe = probeRuntimeSpawnContext(root, rt, { runtimeId: rtId });
504
+ if (probe.ok) {
505
+ const claudeAuthIssue = await getClaudeSubprocessAuthIssue(rt);
506
+ if (claudeAuthIssue) {
507
+ return attachRuntimeContract({
508
+ ...base,
509
+ level: 'warn',
510
+ detail: `${probe.detail} ${claudeAuthIssue.detail} ${claudeAuthIssue.fix}`,
511
+ }, rtId, rt, boundRoleEntries);
512
+ }
513
+ }
503
514
  return attachRuntimeContract({ ...base, level: probe.ok ? 'pass' : 'fail', detail: probe.detail }, rtId, rt, boundRoleEntries);
504
515
  }
505
516
 
@@ -98,7 +98,7 @@ const GOVERNED_ROLES = {
98
98
 
99
99
  const DEFAULT_GOVERNED_LOCAL_DEV_RUNTIME = Object.freeze({
100
100
  type: 'local_cli',
101
- command: ['claude', '--print', '--dangerously-skip-permissions'],
101
+ command: ['claude', '--print', '--dangerously-skip-permissions', '--bare'],
102
102
  cwd: '.',
103
103
  prompt_transport: 'stdin',
104
104
  });
@@ -25,6 +25,7 @@ import { validateParentRun } from '../lib/run-history.js';
25
25
  import { dispatchApiProxy } from '../lib/adapters/api-proxy-adapter.js';
26
26
  import {
27
27
  dispatchLocalCli,
28
+ resolveStartupWatchdogMs,
28
29
  saveDispatchLogs,
29
30
  resolvePromptTransport,
30
31
  } from '../lib/adapters/local-cli-adapter.js';
@@ -52,6 +53,7 @@ import { emitRunEvent } from '../lib/run-events.js';
52
53
  import { checkpointAcceptedTurn } from '../lib/turn-checkpoint.js';
53
54
  import { failTurnStartup } from '../lib/stale-turn-watchdog.js';
54
55
  import { hasMinimumTurnResultShape } from '../lib/turn-result-shape.js';
56
+ import { isKnownTurnRunningProofStream } from '../lib/dispatch-streams.js';
55
57
 
56
58
  export async function runCommand(opts) {
57
59
  const context = loadProjectContext();
@@ -343,7 +345,10 @@ export async function executeGovernedRun(context, opts = {}) {
343
345
  });
344
346
  };
345
347
 
346
- const ensureRunningState = (stream = 'stdout', at = new Date().toISOString()) => {
348
+ const ensureRunningState = (stream = null, at = new Date().toISOString()) => {
349
+ if (stream != null && !isKnownTurnRunningProofStream(stream)) {
350
+ return;
351
+ }
347
352
  if (runningMarked) return;
348
353
  runningMarked = true;
349
354
  transitionActiveTurnLifecycle(projectRoot, turn.turn_id, 'running', { stream, at });
@@ -359,7 +364,16 @@ export async function executeGovernedRun(context, opts = {}) {
359
364
  };
360
365
 
361
366
  const recordOutputActivity = (stream, text) => {
362
- ensureRunningState(stream);
367
+ // DEC-BUG54-STDERR-IS-NOT-STARTUP-PROOF-002 (Turn 88) extended to the
368
+ // run-command lifecycle in Turn 89: stderr activity must NOT promote a
369
+ // turn from `starting` to `running`. stdout (or the adapter's
370
+ // onFirstOutput callback, which is stdout/staged_result only post-Turn
371
+ // 88) is the only signal that satisfies the lifecycle transition.
372
+ // stderr is still tracked by the progress tracker for silence detection
373
+ // and operator diagnostics.
374
+ if (stream != null && isKnownTurnRunningProofStream(stream)) {
375
+ ensureRunningState(stream);
376
+ }
363
377
  const lines = text.split('\n').length - 1 || 1;
364
378
  const wasSilent = tracker.onOutput(stream, lines);
365
379
  if (wasSilent) {
@@ -473,9 +487,10 @@ export async function executeGovernedRun(context, opts = {}) {
473
487
 
474
488
  if (adapterResult.startupFailure) {
475
489
  const freshState = loadProjectState(projectRoot, cfg) || state;
490
+ const startupThresholdMs = resolveStartupWatchdogMs(cfg, runtime);
476
491
  failTurnStartup(projectRoot, freshState, cfg, turn.turn_id, {
477
492
  failure_type: adapterResult.startupFailureType || 'no_subprocess_output',
478
- threshold_ms: cfg?.run_loop?.startup_watchdog_ms ?? 30_000,
493
+ threshold_ms: startupThresholdMs,
479
494
  running_ms: freshState?.active_turns?.[turn.turn_id]?.started_at
480
495
  ? Math.max(0, Date.now() - new Date(freshState.active_turns[turn.turn_id].started_at).getTime())
481
496
  : 0,
@@ -383,7 +383,7 @@ function renderGovernedStatus(context, opts) {
383
383
  console.log(` ${chalk.dim(' or:')} ${chalk.cyan(`agentxchain accept-turn --turn ${turn.turn_id}`)} — re-attempt acceptance`);
384
384
  }
385
385
  if (turn.status === 'failed_start') {
386
- console.log(` ${chalk.dim('Reason:')} ${turn.failed_start_reason || 'no_subprocess_output'}`);
386
+ console.log(` ${chalk.dim('Reason:')} ${normalizeStartupFailureReasonForDisplay(turn.failed_start_reason)}`);
387
387
  const recover = turn.recovery_command || `agentxchain reissue-turn --turn ${turn.turn_id} --reason ghost`;
388
388
  console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
389
389
  }
@@ -443,7 +443,7 @@ function renderGovernedStatus(context, opts) {
443
443
  console.log(` ${chalk.dim(' or:')} ${chalk.cyan(mergeAction.command)}`);
444
444
  }
445
445
  if (singleActiveTurn.status === 'failed_start') {
446
- console.log(` ${chalk.dim('Reason:')} ${singleActiveTurn.failed_start_reason || 'no_subprocess_output'}`);
446
+ console.log(` ${chalk.dim('Reason:')} ${normalizeStartupFailureReasonForDisplay(singleActiveTurn.failed_start_reason)}`);
447
447
  const recover = singleActiveTurn.recovery_command || `agentxchain reissue-turn --turn ${singleActiveTurn.turn_id} --reason ghost`;
448
448
  console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
449
449
  }
@@ -883,6 +883,24 @@ function pluralizeRepoDecisionCount(count, singular, plural) {
883
883
  return `${count} ${count === 1 ? singular : plural}`;
884
884
  }
885
885
 
886
+ // BUG-54 vocabulary discipline (`DEC-BUG54-OPERATOR-SUBTYPE-DISPLAY-001`).
887
+ // Operator-facing status surfaces must render a typed startup-failure subtype,
888
+ // not the raw adapter signal `no_subprocess_output`. Public docs
889
+ // (website-v2/docs/cli.mdx) only document `runtime_spawn_failed` and
890
+ // `stdout_attach_failed` as the operator-visible subtypes; the `no_subprocess_output`
891
+ // label is an internal adapter/classification fallback and must not leak to the
892
+ // CLI status display. The adapter semantics for `no_subprocess_output` ("we
893
+ // watched for stdout and saw none inside the startup watchdog window") are
894
+ // identical to the operator subtype `stdout_attach_failed`, so that is the
895
+ // correct display normalization.
896
+ const TYPED_STARTUP_FAILURE_SUBTYPES = new Set(['runtime_spawn_failed', 'stdout_attach_failed']);
897
+ function normalizeStartupFailureReasonForDisplay(rawReason) {
898
+ if (typeof rawReason === 'string' && TYPED_STARTUP_FAILURE_SUBTYPES.has(rawReason)) {
899
+ return rawReason;
900
+ }
901
+ return 'stdout_attach_failed';
902
+ }
903
+
886
904
  function filterDispatchProgressForActiveTurns(progressByTurn, activeTurns) {
887
905
  const filtered = {};
888
906
  if (!progressByTurn || typeof progressByTurn !== 'object') {
@@ -897,7 +915,7 @@ function filterDispatchProgressForActiveTurns(progressByTurn, activeTurns) {
897
915
  return filtered;
898
916
  }
899
917
 
900
- function formatDispatchActivityLine(progress) {
918
+ export function formatDispatchActivityLine(progress) {
901
919
  if (!progress || typeof progress !== 'object') return null;
902
920
  const lastAct = progress.last_activity_at ? new Date(progress.last_activity_at) : null;
903
921
  const agoSec = lastAct && !Number.isNaN(lastAct.getTime())
@@ -918,6 +936,15 @@ function formatDispatchActivityLine(progress) {
918
936
  if (progress.activity_type === 'response') {
919
937
  return chalk.green('API response received');
920
938
  }
939
+ // DEC-BUG54-DIAGNOSTIC-ACTIVITY-TYPE-001 (Turn 91): stderr-only activity
940
+ // must be rendered as yellow "Diagnostic output only" — never as the green
941
+ // "Producing output" signal that previously leaked onto the operator
942
+ // surface for failing-startup subprocesses whose stdout never attached.
943
+ if (progress.activity_type === 'diagnostic_only') {
944
+ const agoLabel = agoSec != null && agoSec > 0 ? `, last ${agoSec}s ago` : '';
945
+ return chalk.yellow('Diagnostic output only') +
946
+ ` (${progress.stderr_lines || 0} stderr lines, no stdout yet${agoLabel})`;
947
+ }
921
948
  const agoLabel = agoSec != null && agoSec > 0 ? `, last ${agoSec}s ago` : '';
922
949
  return chalk.green('Producing output') + ` (${progress.output_lines || 0} lines${agoLabel})`;
923
950
  }
@@ -49,6 +49,7 @@ import {
49
49
  } from '../lib/adapters/manual-adapter.js';
50
50
  import {
51
51
  dispatchLocalCli,
52
+ resolveStartupWatchdogMs,
52
53
  saveDispatchLogs,
53
54
  resolvePromptTransport,
54
55
  } from '../lib/adapters/local-cli-adapter.js';
@@ -73,6 +74,7 @@ import { shouldSuggestManualQaFallback } from '../lib/manual-qa-fallback.js';
73
74
  import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
74
75
  import { consumeNextApprovedIntent } from '../lib/intake.js';
75
76
  import { failTurnStartup, reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
77
+ import { isKnownTurnRunningProofStream } from '../lib/dispatch-streams.js';
76
78
 
77
79
  export async function stepCommand(opts) {
78
80
  const context = loadProjectContext();
@@ -697,7 +699,10 @@ export async function stepCommand(opts) {
697
699
  state = starting.state;
698
700
  }
699
701
  };
700
- const ensureRunningState = (stream = 'stdout', at = new Date().toISOString()) => {
702
+ const ensureRunningState = (stream = null, at = new Date().toISOString()) => {
703
+ if (stream != null && !isKnownTurnRunningProofStream(stream)) {
704
+ return;
705
+ }
701
706
  if (runningMarked) return;
702
707
  runningMarked = true;
703
708
  const running = transitionActiveTurnLifecycle(root, turn.turn_id, 'running', { stream, at });
@@ -756,9 +761,10 @@ export async function stepCommand(opts) {
756
761
 
757
762
  if (cliResult.startupFailure) {
758
763
  const freshState = loadProjectState(root, config) || state;
764
+ const startupThresholdMs = resolveStartupWatchdogMs(config, runtime);
759
765
  const failed = failTurnStartup(root, freshState, config, turn.turn_id, {
760
766
  failure_type: cliResult.startupFailureType || 'no_subprocess_output',
761
- threshold_ms: config?.run_loop?.startup_watchdog_ms ?? 30_000,
767
+ threshold_ms: startupThresholdMs,
762
768
  running_ms: freshState?.active_turns?.[turn.turn_id]?.started_at
763
769
  ? Math.max(0, Date.now() - new Date(freshState.active_turns[turn.turn_id].started_at).getTime())
764
770
  : 0,
@@ -30,6 +30,7 @@ import {
30
30
  } from '../turn-paths.js';
31
31
  import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
32
32
  import { hasMeaningfulStagedResult } from '../staged-result-proof.js';
33
+ import { getClaudeSubprocessAuthIssue } from '../claude-local-auth.js';
33
34
 
34
35
  const DIAGNOSTIC_ENV_KEYS = [
35
36
  'PATH',
@@ -39,6 +40,7 @@ const DIAGNOSTIC_ENV_KEYS = [
39
40
  'TMPDIR',
40
41
  'AGENTXCHAIN_TURN_ID',
41
42
  ];
43
+ const DIAGNOSTIC_STDERR_EXCERPT_LIMIT = 800;
42
44
 
43
45
  /**
44
46
  * Launch a local CLI subprocess for a governed turn.
@@ -64,7 +66,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
64
66
  onStderr,
65
67
  onSpawnAttached,
66
68
  onFirstOutput,
67
- startupWatchdogMs = config?.run_loop?.startup_watchdog_ms ?? 30_000,
69
+ startupWatchdogMs: startupWatchdogOverrideMs,
68
70
  turnId,
69
71
  } = options;
70
72
 
@@ -85,6 +87,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
85
87
  if (!runtime) {
86
88
  return { ok: false, error: `Runtime "${runtimeId}" not found in config` };
87
89
  }
90
+ const startupWatchdogMs = startupWatchdogOverrideMs ?? resolveStartupWatchdogMs(config, runtime);
88
91
 
89
92
  // Read the dispatch bundle prompt
90
93
  const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
@@ -125,6 +128,22 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
125
128
  const spawnEnv = { ...process.env, AGENTXCHAIN_TURN_ID: turn.turn_id };
126
129
  const stdinBytes = transport === 'stdin' ? Buffer.byteLength(fullPrompt, 'utf8') : 0;
127
130
  const diagnosticArgs = redactPromptArgs(args, fullPrompt, transport);
131
+ const claudeAuthIssue = await getClaudeSubprocessAuthIssue(runtime, spawnEnv);
132
+
133
+ if (claudeAuthIssue) {
134
+ appendDiagnostic(logs, 'claude_auth_preflight_failed', {
135
+ runtime_id: runtimeId,
136
+ turn_id: turn.turn_id,
137
+ auth_env_present: claudeAuthIssue.auth_env_present,
138
+ smoke_probe: claudeAuthIssue.smoke_probe,
139
+ recommendation: claudeAuthIssue.fix,
140
+ });
141
+ return {
142
+ ok: false,
143
+ error: `${claudeAuthIssue.detail} ${claudeAuthIssue.fix}`,
144
+ logs,
145
+ };
146
+ }
128
147
 
129
148
  return new Promise((resolve) => {
130
149
  if (signal?.aborted) {
@@ -163,6 +182,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
163
182
 
164
183
  let settled = false;
165
184
  let firstOutputAt = null;
185
+ let firstOutputStream = null;
166
186
  let spawnConfirmedAt = null;
167
187
  let spawnConfirmedAtMs = null;
168
188
  let firstOutputLatencyMs = null;
@@ -171,6 +191,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
171
191
  let startupFailureType = null;
172
192
  let stdoutBytes = 0;
173
193
  let stderrBytes = 0;
194
+ let stderrExcerpt = '';
174
195
 
175
196
  const settle = (result) => {
176
197
  if (settled) return;
@@ -211,6 +232,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
211
232
  const recordFirstOutput = (stream) => {
212
233
  if (firstOutputAt) return;
213
234
  firstOutputAt = new Date().toISOString();
235
+ firstOutputStream = stream;
214
236
  firstOutputLatencyMs = spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs);
215
237
  clearStartupWatchdog();
216
238
  appendDiagnostic(logs, 'first_output', {
@@ -280,7 +302,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
280
302
  child.stderr.on('data', (chunk) => {
281
303
  const text = chunk.toString();
282
304
  stderrBytes += Buffer.byteLength(text);
283
- recordFirstOutput('stderr');
305
+ stderrExcerpt = appendDiagnosticExcerpt(stderrExcerpt, text, DIAGNOSTIC_STDERR_EXCERPT_LIMIT);
284
306
  logs.push('[stderr] ' + text);
285
307
  if (onStderr) onStderr(text);
286
308
  });
@@ -349,14 +371,20 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
349
371
  pid: child.pid ?? null,
350
372
  exit_code: exitCode,
351
373
  signal: killSignal,
374
+ exit_signal: killSignal,
352
375
  spawn_confirmed_at: spawnConfirmedAt,
353
376
  elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
354
377
  first_output_at: firstOutputAt,
378
+ first_output_stream: firstOutputStream,
355
379
  startup_latency_ms: firstOutputLatencyMs,
356
380
  stdout_bytes: stdoutBytes,
357
381
  stderr_bytes: stderrBytes,
358
382
  staged_result_ready: hasResult,
383
+ watchdog_fired: startupTimedOut,
359
384
  };
385
+ if (stderrExcerpt) {
386
+ exitDiagnostic.stderr_excerpt = stderrExcerpt;
387
+ }
360
388
  if (startupTimedOut) {
361
389
  exitDiagnostic.startup_failure_type = startupFailureType || 'no_subprocess_output';
362
390
  } else if (!spawnConfirmedAt) {
@@ -543,6 +571,16 @@ function resolvePromptTransport(runtime) {
543
571
  return hasPlaceholder ? 'argv' : 'dispatch_bundle_only';
544
572
  }
545
573
 
574
+ function resolveStartupWatchdogMs(config, runtime) {
575
+ if (runtime?.type === 'local_cli' && Number.isInteger(runtime?.startup_watchdog_ms) && runtime.startup_watchdog_ms > 0) {
576
+ return runtime.startup_watchdog_ms;
577
+ }
578
+ if (Number.isInteger(config?.run_loop?.startup_watchdog_ms) && config.run_loop.startup_watchdog_ms > 0) {
579
+ return config.run_loop.startup_watchdog_ms;
580
+ }
581
+ return 30_000;
582
+ }
583
+
546
584
  /**
547
585
  * Check if the staged result file exists and has meaningful content.
548
586
  * Delegates to the shared `hasMeaningfulStagedResult` helper so watchdog,
@@ -595,4 +633,14 @@ function normalizeDiagnosticError(err) {
595
633
  };
596
634
  }
597
635
 
636
+ function appendDiagnosticExcerpt(existing, chunk, limit) {
637
+ const combined = `${existing}${chunk}`;
638
+ if (combined.length <= limit) {
639
+ return combined;
640
+ }
641
+ return combined.slice(combined.length - limit);
642
+ }
643
+
644
+ export { resolveCommand };
598
645
  export { resolvePromptTransport };
646
+ export { resolveStartupWatchdogMs };
@@ -0,0 +1,237 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ const CLAUDE_ENV_AUTH_KEYS = [
4
+ 'ANTHROPIC_API_KEY',
5
+ 'CLAUDE_API_KEY',
6
+ 'CLAUDE_CODE_OAUTH_TOKEN',
7
+ 'CLAUDE_CODE_USE_VERTEX',
8
+ 'CLAUDE_CODE_USE_BEDROCK',
9
+ ];
10
+
11
+ const DEFAULT_SMOKE_PROBE_TIMEOUT_MS = 10_000;
12
+ const DEFAULT_SMOKE_PROBE_STDIN = 'ok';
13
+
14
+ function normalizeCommandTokens(runtime) {
15
+ if (Array.isArray(runtime?.command)) {
16
+ return runtime.command.flatMap((element) =>
17
+ typeof element === 'string' ? element.trim().split(/\s+/).filter(Boolean) : []
18
+ );
19
+ }
20
+ if (typeof runtime?.command === 'string' && runtime.command.trim()) {
21
+ return runtime.command.trim().split(/\s+/).filter(Boolean);
22
+ }
23
+ return [];
24
+ }
25
+
26
+ export function isClaudeLocalCliRuntime(runtime) {
27
+ const tokens = normalizeCommandTokens(runtime);
28
+ if (tokens.length === 0) {
29
+ return false;
30
+ }
31
+ const head = tokens[0].toLowerCase();
32
+ return head === 'claude' || head.endsWith('/claude');
33
+ }
34
+
35
+ export function hasClaudeBareFlag(runtime) {
36
+ return normalizeCommandTokens(runtime).includes('--bare');
37
+ }
38
+
39
+ export function getClaudeEnvAuthPresence(env = process.env) {
40
+ return Object.fromEntries(
41
+ CLAUDE_ENV_AUTH_KEYS.map((key) => [key, Boolean(env?.[key])]),
42
+ );
43
+ }
44
+
45
+ export function hasClaudeEnvAuth(env = process.env) {
46
+ return Object.values(getClaudeEnvAuthPresence(env)).some(Boolean);
47
+ }
48
+
49
+ function buildClaudeSubprocessAuthIssue(env, smokeProbe = null) {
50
+ const auth_env_present = getClaudeEnvAuthPresence(env);
51
+ return {
52
+ auth_env_present,
53
+ smoke_probe: smokeProbe,
54
+ detail: 'Claude local_cli runtime has no env-based auth and is missing "--bare"; non-interactive subprocesses can hang on macOS keychain reads.',
55
+ fix: 'Export ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN before running AgentXchain, or add "--bare" to the Claude command if you intentionally want env-only auth.',
56
+ };
57
+ }
58
+
59
+ function resolveSmokeProbeTimeoutMs(env, options = {}) {
60
+ if (Number.isFinite(options?.timeoutMs) && options.timeoutMs > 0) {
61
+ return options.timeoutMs;
62
+ }
63
+ const raw = env?.AGENTXCHAIN_CLAUDE_AUTH_PROBE_TIMEOUT_MS;
64
+ const parsed = Number.parseInt(raw, 10);
65
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SMOKE_PROBE_TIMEOUT_MS;
66
+ }
67
+
68
+ export async function getClaudeSubprocessAuthIssue(runtime, env = process.env, options = {}) {
69
+ if (!isClaudeLocalCliRuntime(runtime)) {
70
+ return null;
71
+ }
72
+
73
+ if (hasClaudeBareFlag(runtime) || hasClaudeEnvAuth(env)) {
74
+ return null;
75
+ }
76
+
77
+ const smokeProbe = await runClaudeSmokeProbe({
78
+ runtime,
79
+ env,
80
+ timeoutMs: resolveSmokeProbeTimeoutMs(env, options),
81
+ stdinPayload: options?.stdinPayload,
82
+ spawnImpl: options?.spawnImpl,
83
+ });
84
+
85
+ if (smokeProbe.kind === 'stdout_observed' || smokeProbe.kind === 'spawn_error' || smokeProbe.kind === 'skipped') {
86
+ return null;
87
+ }
88
+
89
+ if (smokeProbe.kind === 'hang' || smokeProbe.kind === 'exit_nonzero' || smokeProbe.kind === 'stderr_only') {
90
+ return buildClaudeSubprocessAuthIssue(env, smokeProbe);
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Bounded smoke probe that spawns the runtime's actual Claude command with a
98
+ * tiny prompt on stdin and a watchdog. Returns a classification:
99
+ *
100
+ * { kind: 'stdout_observed' } — real stdout arrived before watchdog;
101
+ * the setup is NOT hanging on auth.
102
+ * { kind: 'hang', elapsed_ms } — watchdog fired with no stdout/stderr
103
+ * bytes; the keychain-hang shape (BUG-54).
104
+ * { kind: 'stderr_only', ... } — process wrote stderr but no stdout
105
+ * before watchdog (auth error or similar).
106
+ * { kind: 'exit_nonzero', ... } — process exited non-zero with no stdout
107
+ * (explicit auth failure — not a hang).
108
+ * { kind: 'spawn_error', ... } — spawn itself failed (ENOENT / EPERM).
109
+ * { kind: 'skipped', reason } — probe disabled or unavailable.
110
+ *
111
+ * This is the positive-case-testable alternative to the static shape-check in
112
+ * `getClaudeSubprocessAuthIssue`: it observes what the subprocess actually
113
+ * does rather than predicting what it *might* do from config shape alone.
114
+ *
115
+ * Added 2026-04-21 for the BUG-56 false-positive fix. See
116
+ * `.planning/BUG_56_FALSE_POSITIVE_RETRO.md` for the decision trail.
117
+ *
118
+ * @param {{ runtime: object, env?: object, timeoutMs?: number, stdinPayload?: string, spawnImpl?: Function }} opts
119
+ * @returns {Promise<object>}
120
+ */
121
+ export async function runClaudeSmokeProbe(opts) {
122
+ const runtime = opts?.runtime ?? null;
123
+ const env = opts?.env ?? process.env;
124
+ const timeoutMs = Number.isFinite(opts?.timeoutMs) ? opts.timeoutMs : DEFAULT_SMOKE_PROBE_TIMEOUT_MS;
125
+ const stdinPayload = typeof opts?.stdinPayload === 'string' ? opts.stdinPayload : DEFAULT_SMOKE_PROBE_STDIN;
126
+ const spawnImpl = typeof opts?.spawnImpl === 'function' ? opts.spawnImpl : spawn;
127
+
128
+ if (!isClaudeLocalCliRuntime(runtime)) {
129
+ return { kind: 'skipped', reason: 'not_claude_local_cli' };
130
+ }
131
+
132
+ const tokens = normalizeCommandTokens(runtime);
133
+ if (tokens.length === 0) {
134
+ return { kind: 'skipped', reason: 'empty_command' };
135
+ }
136
+ const [command, ...args] = tokens;
137
+
138
+ return new Promise((resolve) => {
139
+ let child;
140
+ try {
141
+ child = spawnImpl(command, args, {
142
+ stdio: ['pipe', 'pipe', 'pipe'],
143
+ env,
144
+ });
145
+ } catch (error) {
146
+ resolve({
147
+ kind: 'spawn_error',
148
+ errno: error?.errno ?? null,
149
+ code: error?.code ?? null,
150
+ message: error?.message ?? String(error),
151
+ });
152
+ return;
153
+ }
154
+
155
+ if (!child || typeof child.on !== 'function') {
156
+ resolve({ kind: 'spawn_error', code: 'NO_CHILD_HANDLE', message: 'spawn returned no child handle' });
157
+ return;
158
+ }
159
+
160
+ const start = Date.now();
161
+ let stdoutBytes = 0;
162
+ let stderrBytes = 0;
163
+ let stderrBuf = '';
164
+ let settled = false;
165
+
166
+ const finish = (result) => {
167
+ if (settled) return;
168
+ settled = true;
169
+ try { child.kill('SIGTERM'); } catch { /* ignore */ }
170
+ resolve(result);
171
+ };
172
+
173
+ const watchdog = setTimeout(() => {
174
+ const elapsed_ms = Date.now() - start;
175
+ if (stdoutBytes > 0) {
176
+ finish({ kind: 'stdout_observed', elapsed_ms });
177
+ } else if (stderrBytes > 0) {
178
+ finish({ kind: 'stderr_only', elapsed_ms, stderr_snippet: stderrBuf.slice(0, 500) });
179
+ } else {
180
+ finish({ kind: 'hang', elapsed_ms });
181
+ }
182
+ }, timeoutMs);
183
+ if (typeof watchdog.unref === 'function') watchdog.unref();
184
+
185
+ child.stdout?.on('data', (chunk) => {
186
+ stdoutBytes += chunk.length;
187
+ if (stdoutBytes > 0 && !settled) {
188
+ clearTimeout(watchdog);
189
+ finish({ kind: 'stdout_observed', elapsed_ms: Date.now() - start });
190
+ }
191
+ });
192
+
193
+ child.stderr?.on('data', (chunk) => {
194
+ stderrBytes += chunk.length;
195
+ stderrBuf += chunk.toString('utf8');
196
+ });
197
+
198
+ child.on('error', (error) => {
199
+ clearTimeout(watchdog);
200
+ finish({
201
+ kind: 'spawn_error',
202
+ errno: error?.errno ?? null,
203
+ code: error?.code ?? null,
204
+ message: error?.message ?? String(error),
205
+ });
206
+ });
207
+
208
+ child.on('exit', (code, signal) => {
209
+ if (settled) return;
210
+ clearTimeout(watchdog);
211
+ const elapsed_ms = Date.now() - start;
212
+ if (stdoutBytes > 0) {
213
+ finish({ kind: 'stdout_observed', elapsed_ms });
214
+ } else if (code !== 0) {
215
+ finish({
216
+ kind: 'exit_nonzero',
217
+ elapsed_ms,
218
+ exit_code: code,
219
+ exit_signal: signal,
220
+ stderr_snippet: stderrBuf.slice(0, 500),
221
+ });
222
+ } else if (stderrBytes > 0) {
223
+ finish({ kind: 'stderr_only', elapsed_ms, stderr_snippet: stderrBuf.slice(0, 500) });
224
+ } else {
225
+ finish({ kind: 'hang', elapsed_ms });
226
+ }
227
+ });
228
+
229
+ try {
230
+ child.stdin?.end(`${stdinPayload}\n`);
231
+ } catch {
232
+ // best-effort; error will surface via 'error' event if real
233
+ }
234
+ });
235
+ }
236
+
237
+ export { CLAUDE_ENV_AUTH_KEYS, normalizeCommandTokens };