agentxchain 2.149.1 → 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.
package/README.md CHANGED
@@ -89,7 +89,7 @@ agentxchain step --role pm
89
89
 
90
90
  If you skipped `--goal` during scaffold, run `agentxchain config --set project.goal "Build an API change planner for release teams"` before the first governed turn instead of re-running init in place.
91
91
 
92
- The default governed dev runtime is `claude --print --dangerously-skip-permissions` with stdin prompt delivery. The non-interactive governed path needs write access, so do not pretend bare `claude --print` is sufficient for unattended implementation turns. If your local coding agent uses a different launch contract, set it during scaffold creation:
92
+ The default governed dev runtime is `claude --print --dangerously-skip-permissions --bare` with stdin prompt delivery. The non-interactive governed path needs write access and env-based auth, so do not pretend plain `claude --print` is sufficient for unattended implementation turns. If your local coding agent uses a different launch contract, set it during scaffold creation:
93
93
 
94
94
  ```bash
95
95
  agentxchain init --governed --dir my-agentxchain-project --dev-command ./scripts/dev-agent.sh --dev-prompt-transport dispatch_bundle_only -y
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.149.1",
3
+ "version": "2.149.2",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,7 +59,7 @@ export async function doctorCommand(opts = {}) {
59
59
 
60
60
  // ── Governed (v4) Doctor ────────────────────────────────────────────────────
61
61
 
62
- function governedDoctor(root, rawConfig, opts) {
62
+ async function governedDoctor(root, rawConfig, opts) {
63
63
  const checks = [];
64
64
  const cliVersionHealth = getCliVersionHealth();
65
65
  let stateRunId = null;
@@ -93,7 +93,7 @@ function governedDoctor(root, rawConfig, opts) {
93
93
  const runtimes = (normalized && normalized.runtimes) || rawConfig.runtimes || {};
94
94
  const rolesByRuntime = buildRolesByRuntime(normalized?.roles || {});
95
95
  for (const [rtId, rt] of Object.entries(runtimes)) {
96
- const check = checkRuntimeReachable(root, rtId, rt, rolesByRuntime[rtId] || []);
96
+ const check = await checkRuntimeReachable(root, rtId, rt, rolesByRuntime[rtId] || []);
97
97
  checks.push(check);
98
98
  }
99
99
  const connectorProbe = getConnectorProbeRecommendation(runtimes);
@@ -488,7 +488,7 @@ function buildCliVersionCheck(cliVersionHealth) {
488
488
  };
489
489
  }
490
490
 
491
- function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
491
+ async function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
492
492
  const base = { id: `runtime_${rtId}`, name: `Runtime: ${rtId}` };
493
493
 
494
494
  if (!rt || !rt.type) {
@@ -502,7 +502,7 @@ function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
502
502
  case 'local_cli': {
503
503
  const probe = probeRuntimeSpawnContext(root, rt, { runtimeId: rtId });
504
504
  if (probe.ok) {
505
- const claudeAuthIssue = getClaudeSubprocessAuthIssue(rt);
505
+ const claudeAuthIssue = await getClaudeSubprocessAuthIssue(rt);
506
506
  if (claudeAuthIssue) {
507
507
  return attachRuntimeContract({
508
508
  ...base,
@@ -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
  });
@@ -128,13 +128,14 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
128
128
  const spawnEnv = { ...process.env, AGENTXCHAIN_TURN_ID: turn.turn_id };
129
129
  const stdinBytes = transport === 'stdin' ? Buffer.byteLength(fullPrompt, 'utf8') : 0;
130
130
  const diagnosticArgs = redactPromptArgs(args, fullPrompt, transport);
131
- const claudeAuthIssue = getClaudeSubprocessAuthIssue(runtime, spawnEnv);
131
+ const claudeAuthIssue = await getClaudeSubprocessAuthIssue(runtime, spawnEnv);
132
132
 
133
133
  if (claudeAuthIssue) {
134
134
  appendDiagnostic(logs, 'claude_auth_preflight_failed', {
135
135
  runtime_id: runtimeId,
136
136
  turn_id: turn.turn_id,
137
137
  auth_env_present: claudeAuthIssue.auth_env_present,
138
+ smoke_probe: claudeAuthIssue.smoke_probe,
138
139
  recommendation: claudeAuthIssue.fix,
139
140
  });
140
141
  return {
@@ -1,3 +1,5 @@
1
+ import { spawn } from 'node:child_process';
2
+
1
3
  const CLAUDE_ENV_AUTH_KEYS = [
2
4
  'ANTHROPIC_API_KEY',
3
5
  'CLAUDE_API_KEY',
@@ -6,6 +8,9 @@ const CLAUDE_ENV_AUTH_KEYS = [
6
8
  'CLAUDE_CODE_USE_BEDROCK',
7
9
  ];
8
10
 
11
+ const DEFAULT_SMOKE_PROBE_TIMEOUT_MS = 10_000;
12
+ const DEFAULT_SMOKE_PROBE_STDIN = 'ok';
13
+
9
14
  function normalizeCommandTokens(runtime) {
10
15
  if (Array.isArray(runtime?.command)) {
11
16
  return runtime.command.flatMap((element) =>
@@ -41,7 +46,26 @@ export function hasClaudeEnvAuth(env = process.env) {
41
46
  return Object.values(getClaudeEnvAuthPresence(env)).some(Boolean);
42
47
  }
43
48
 
44
- export function getClaudeSubprocessAuthIssue(runtime, env = process.env) {
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 = {}) {
45
69
  if (!isClaudeLocalCliRuntime(runtime)) {
46
70
  return null;
47
71
  }
@@ -50,12 +74,164 @@ export function getClaudeSubprocessAuthIssue(runtime, env = process.env) {
50
74
  return null;
51
75
  }
52
76
 
53
- const auth_env_present = getClaudeEnvAuthPresence(env);
54
- return {
55
- auth_env_present,
56
- detail: 'Claude local_cli runtime has no env-based auth and is missing "--bare"; non-interactive subprocesses can hang on macOS keychain reads.',
57
- 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.',
58
- };
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
+ });
59
235
  }
60
236
 
61
237
  export { CLAUDE_ENV_AUTH_KEYS, normalizeCommandTokens };
@@ -165,30 +165,30 @@ async function probeLocalCommand(runtimeId, runtime, probeKindLabel, options = {
165
165
  };
166
166
  }
167
167
 
168
- const spawnProbe = probeRuntimeSpawnContext(options.root || process.cwd(), runtime, { runtimeId });
169
- const claudeAuthIssue = getClaudeSubprocessAuthIssue(runtime);
170
-
171
- // DEC-BUG54-CLAUDE-AUTH-PREFLIGHT-001 / DEC-BUG54-VALIDATE-AUTH-PREFLIGHT-001
172
- // Auth-preflight is a config-shape defect that must fire regardless of whether
173
- // the binary currently resolves on PATH. Matches connector-validate.js:108-138
174
- // ordering: a Claude local_cli runtime with no env auth and no --bare is a
175
- // deterministic hang-on-spawn shape the operator must fix before anything
176
- // else. If they fix auth (or add --bare) but still do not have claude
177
- // installed, the next connector check surfaces command_presence after they
178
- // fix the config — that is the correct operator progression.
168
+ const claudeAuthIssue = await getClaudeSubprocessAuthIssue(runtime, process.env, {
169
+ timeoutMs: options.claudeAuthProbeTimeoutMs,
170
+ });
171
+
172
+ // DEC-BUG56-PREFLIGHT-PROBE-OVER-SHAPE-CHECK-001
173
+ // Auth-preflight is observation-based: a no-env/no-bare Claude runtime is
174
+ // only refused when a bounded smoke probe actually hangs or fails without
175
+ // stdout. Working Claude Max keychain setups must pass this gate.
179
176
  if (claudeAuthIssue) {
180
177
  return {
181
178
  ...base,
182
179
  level: 'fail',
183
180
  probe_kind: 'auth_preflight',
184
- command: spawnProbe.command || head,
181
+ command: formatTarget(runtime) || head,
185
182
  error_code: 'claude_auth_preflight_failed',
186
183
  detail: claudeAuthIssue.detail,
187
184
  fix: claudeAuthIssue.fix,
188
185
  auth_env_present: claudeAuthIssue.auth_env_present,
186
+ smoke_probe: claudeAuthIssue.smoke_probe,
189
187
  };
190
188
  }
191
189
 
190
+ const spawnProbe = probeRuntimeSpawnContext(options.root || process.cwd(), runtime, { runtimeId });
191
+
192
192
  if (!spawnProbe.ok) {
193
193
  return {
194
194
  ...base,
@@ -401,8 +401,6 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
401
401
  // Prompt transport validation
402
402
  const transport = runtime.prompt_transport || 'dispatch_bundle_only';
403
403
  const knownTransports = KNOWN_CLI_TRANSPORTS[binaryName];
404
- const claudeAuthIssue = getClaudeSubprocessAuthIssue(runtime);
405
-
406
404
  if (transport === 'argv' && !commandTokens.some((token) => token.includes('{prompt}'))) {
407
405
  warnings.push({
408
406
  probe_kind: 'transport_intent',
@@ -422,15 +420,6 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
422
420
  });
423
421
  }
424
422
 
425
- if (claudeAuthIssue) {
426
- warnings.push({
427
- probe_kind: 'auth_preflight',
428
- level: 'warn',
429
- detail: claudeAuthIssue.detail,
430
- fix: claudeAuthIssue.fix,
431
- });
432
- }
433
-
434
423
  return { warnings };
435
424
  }
436
425
 
@@ -105,11 +105,10 @@ export async function validateConfiguredConnector(sourceRoot, options = {}) {
105
105
  };
106
106
  }
107
107
 
108
- // DEC-BUG54-CLAUDE-AUTH-PREFLIGHT-001 — refuse the known-hanging Claude
109
- // local_cli shape before burning the scratch-workspace + synthetic-dispatch
110
- // ceremony. The adapter also refuses this shape via `claude_auth_preflight_failed`,
111
- // but the operator gets a faster, identical-fix message if we catch it here.
112
- const claudeAuthIssue = getClaudeSubprocessAuthIssue(runtime);
108
+ // DEC-BUG56-PREFLIGHT-PROBE-OVER-SHAPE-CHECK-001 — refuse Claude local_cli
109
+ // auth-hang shapes only after a bounded smoke probe observes no stdout.
110
+ // Working Claude Max keychain setups must pass instead of false-positive.
111
+ const claudeAuthIssue = await getClaudeSubprocessAuthIssue(runtime);
113
112
  if (claudeAuthIssue) {
114
113
  return {
115
114
  ok: false,
@@ -131,6 +130,7 @@ export async function validateConfiguredConnector(sourceRoot, options = {}) {
131
130
  error_code: 'claude_auth_preflight_failed',
132
131
  error: claudeAuthIssue.detail,
133
132
  auth_env_present: claudeAuthIssue.auth_env_present,
133
+ smoke_probe: claudeAuthIssue.smoke_probe,
134
134
  fix: claudeAuthIssue.fix,
135
135
  dispatch: null,
136
136
  validation: null,
@@ -80,7 +80,7 @@
80
80
  "manual-pm": { "type": "manual" },
81
81
  "local-dev": {
82
82
  "type": "local_cli",
83
- "command": ["claude", "--print", "--dangerously-skip-permissions"],
83
+ "command": ["claude", "--print", "--dangerously-skip-permissions", "--bare"],
84
84
  "cwd": ".",
85
85
  "prompt_transport": "stdin"
86
86
  },
@@ -44,25 +44,25 @@
44
44
  "runtimes": {
45
45
  "local-pm": {
46
46
  "type": "local_cli",
47
- "command": ["claude", "--print", "--dangerously-skip-permissions"],
47
+ "command": ["claude", "--print", "--dangerously-skip-permissions", "--bare"],
48
48
  "cwd": ".",
49
49
  "prompt_transport": "stdin"
50
50
  },
51
51
  "local-dev": {
52
52
  "type": "local_cli",
53
- "command": ["claude", "--print", "--dangerously-skip-permissions"],
53
+ "command": ["claude", "--print", "--dangerously-skip-permissions", "--bare"],
54
54
  "cwd": ".",
55
55
  "prompt_transport": "stdin"
56
56
  },
57
57
  "local-qa": {
58
58
  "type": "local_cli",
59
- "command": ["claude", "--print", "--dangerously-skip-permissions"],
59
+ "command": ["claude", "--print", "--dangerously-skip-permissions", "--bare"],
60
60
  "cwd": ".",
61
61
  "prompt_transport": "stdin"
62
62
  },
63
63
  "local-director": {
64
64
  "type": "local_cli",
65
- "command": ["claude", "--print", "--dangerously-skip-permissions"],
65
+ "command": ["claude", "--print", "--dangerously-skip-permissions", "--bare"],
66
66
  "cwd": ".",
67
67
  "prompt_transport": "stdin"
68
68
  }