Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions packages/agent-connector/src/adapters/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const path = require('path');
const { execSync, spawn } = require('child_process');

const BaseAdapter = require('./base');
const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle, redactSecrets } = require('./utils');
const { buildClaudeSystemPrompt, buildClaudeSkillMd, workspaceSkillName } = require('./workspace-prompt');
const { pinnedFingerprint, sampleRecap } = require('./decision-log');
const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
Expand Down Expand Up @@ -737,9 +737,17 @@ class ClaudeAdapter extends BaseAdapter {
}

if (consecutiveTimeouts >= this._WATCHDOG_MAX_TIMEOUTS) {
const tail = this._stderrTail(pp);
this._log(`Watchdog: process unresponsive for ${consecutiveTimeouts * 15}s on ${pp.msgChannel} — killing`);
if (tail) this._log(`stderr: ${tail}`);
this._stopWatchdog(pp);
try { await this.sendError(pp.msgChannel, 'Agent process became unresponsive and was restarted.'); } catch {}
try {
await this.sendError(
pp.msgChannel,
'Agent process became unresponsive and was restarted.' +
(tail ? `\n\n\`\`\`\n${tail}\n\`\`\`` : ''),
);
} catch {}
if (pp.messageResolve) {
const resolve = pp.messageResolve;
pp.messageResolve = null;
Expand Down Expand Up @@ -926,6 +934,8 @@ class ClaudeAdapter extends BaseAdapter {

proc.on('exit', (code) => {
this._log(`Persistent process exited: channel=${channel} code=${code}`);
const tail = this._stderrTail(pp);
if (tail) this._log(`stderr: ${tail}`);
pp.alive = false;
if (pp.idleTimer) clearTimeout(pp.idleTimer);
this._stopWatchdog(pp);
Expand Down Expand Up @@ -992,6 +1002,22 @@ class ClaudeAdapter extends BaseAdapter {
});
}

/**
* What the CLI wrote to stderr this turn, trimmed and redacted, or ''.
*
* `stderrBuf` was collected and then read by nothing at all: when the process
* died before emitting a single JSON event, or hung without emitting one, the
* only account of why sat in a string no code path ever looked at. The channel
* got "No response generated" and the daemon log got an exit code, so a run
* that had the reason in hand reported none. codex, gemini and opencode all
* log their stderr on exit; this is that, plus the two paths — watchdog kill
* and silent exit — where it is the ONLY thing there is to report.
*/
_stderrTail(pp) {
const tail = String((pp && pp.stderrBuf) || '').trim();
return tail ? redactSecrets(tail).slice(-800) : '';
}

/**
* Turn a raw Claude `result` error into a user-facing message. Auth failures
* (401 / invalid token) are the most common real-world cause and are otherwise
Expand Down Expand Up @@ -1291,10 +1317,15 @@ class ClaudeAdapter extends BaseAdapter {
continue;
}
if (!pp.everPostedAnything) {
if (pp.lastErrorText) {
try { await this.sendError(msgChannel, this._formatClaudeError(pp.lastErrorText)); } catch {}
// stderr is the fallback, not nothing: a CLI that dies before its
// first JSON event leaves no `result` to read, and "No response
// generated. Please try again." is what turned those into an
// unreportable failure.
const detail = pp.lastErrorText || this._stderrTail(pp);
if (detail) {
try { await this.sendError(msgChannel, this._formatClaudeError(detail)); } catch {}
} else {
try { await this.sendResponse(msgChannel, 'No response generated. Please try again.'); } catch {}
try { await this.sendResponse(msgChannel, `No response generated (exit code ${result.code === undefined ? 'unknown' : result.code}). Please try again.`); } catch {}
}
}
break;
Expand Down
14 changes: 2 additions & 12 deletions packages/agent-connector/src/adapters/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const https = require('https');

const { whereBinary } = require('../paths');
const BaseAdapter = require('./base');
const { redactSecrets } = require('./utils');
const { buildOpenclawSystemPrompt } = require('./workspace-prompt');

const IS_WINDOWS = process.platform === 'win32';
Expand Down Expand Up @@ -535,18 +536,7 @@ class CodexAdapter extends BaseAdapter {

/** Redact secrets (keys, tokens, bearer/authorization, query secrets) from diagnostics. */
static _redact(s) {
let out = String(s == null ? '' : s);
out = out
.replace(/\bsk-[A-Za-z0-9_-]{6,}/g, 'sk-[REDACTED]')
.replace(/\b(?:github_pat|gh[pousr])_[A-Za-z0-9_]{10,}/g, '[REDACTED_TOKEN]')
.replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}/g, '[REDACTED_TOKEN]')
.replace(/\bAKIA[0-9A-Z]{12,}/g, '[REDACTED_KEY]')
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, '[REDACTED_JWT]')
.replace(/(authorization|api[_-]?key|x-api-key|token|bearer|secret|password|passwd)(["'\s:=]+)([^\s"',}]+)/gi,
(m, k, sep) => `${k}${sep}[REDACTED]`)
.replace(/([?&](?:api[_-]?key|key|token|access_token)=)[^&\s"']+/gi, '$1[REDACTED]')
.replace(/\b[A-Za-z0-9_-]{40,}\b/g, '[REDACTED]');
return out;
return redactSecrets(s);
}

// ------------------------------------------------------------------
Expand Down
15 changes: 2 additions & 13 deletions packages/agent-connector/src/adapters/opencode.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const crypto = require('crypto');
const { execSync, spawn } = require('child_process');

const BaseAdapter = require('./base');
const { formatAttachmentsForPrompt } = require('./utils');
const { formatAttachmentsForPrompt, redactSecrets } = require('./utils');
const { buildOpenCodeSkillMd, buildOpenCodeSystemPrompt, workspaceSkillName } = require('./workspace-prompt');
const { whichBinary, whereBinary, getEnhancedEnv } = require('../paths');

Expand Down Expand Up @@ -1224,18 +1224,7 @@ class OpenCodeAdapter extends BaseAdapter {

/** Redact secrets (keys, tokens, bearer/authorization, query secrets) from diagnostics. */
static _redact(s) {
let out = String(s == null ? '' : s);
out = out
.replace(/\bsk-[A-Za-z0-9_-]{6,}/g, 'sk-[REDACTED]')
.replace(/\b(?:github_pat|gh[pousr])_[A-Za-z0-9_]{10,}/g, '[REDACTED_TOKEN]')
.replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}/g, '[REDACTED_TOKEN]')
.replace(/\bAKIA[0-9A-Z]{12,}/g, '[REDACTED_KEY]')
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, '[REDACTED_JWT]')
.replace(/(authorization|api[_-]?key|x-api-key|token|bearer|secret|password|passwd)(["'\s:=]+)([^\s"',}]+)/gi,
(m, k, sep) => `${k}${sep}[REDACTED]`)
.replace(/([?&](?:api[_-]?key|key|token|access_token)=)[^&\s"']+/gi, '$1[REDACTED]')
.replace(/\b[A-Za-z0-9_-]{40,}\b/g, '[REDACTED]');
return out;
return redactSecrets(s);
}
}

Expand Down
27 changes: 27 additions & 0 deletions packages/agent-connector/src/adapters/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,35 @@ function formatAttachmentsForPrompt(
return lines.join('\n');
}

/**
* Strip secrets out of anything on its way to a log line or a channel message.
*
* Adapter diagnostics quote raw CLI output, and a CLI that fails on auth tends
* to echo the credential it was handed. The shapes here are the ones that
* actually turn up in that output; the closing catch-all takes any long opaque
* token the named patterns missed.
*
* Lived as a private static on two adapters before claude needed it as well —
* a third identical copy is one copy too many for a security-relevant rule.
*/
function redactSecrets(s) {
let out = String(s == null ? '' : s);
out = out
.replace(/\bsk-[A-Za-z0-9_-]{6,}/g, 'sk-[REDACTED]')
.replace(/\b(?:github_pat|gh[pousr])_[A-Za-z0-9_]{10,}/g, '[REDACTED_TOKEN]')
.replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}/g, '[REDACTED_TOKEN]')
.replace(/\bAKIA[0-9A-Z]{12,}/g, '[REDACTED_KEY]')
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, '[REDACTED_JWT]')
.replace(/(authorization|api[_-]?key|x-api-key|token|bearer|secret|password|passwd)(["'\s:=]+)([^\s"',}]+)/gi,
(m, k, sep) => `${k}${sep}[REDACTED]`)
.replace(/([?&](?:api[_-]?key|key|token|access_token)=)[^&\s"']+/gi, '$1[REDACTED]')
.replace(/\b[A-Za-z0-9_-]{40,}\b/g, '[REDACTED]');
return out;
}

module.exports = {
SESSION_DEFAULT_RE,
generateSessionTitle,
formatAttachmentsForPrompt,
redactSecrets,
};
50 changes: 50 additions & 0 deletions packages/agent-connector/test/claude-stderr-surfacing.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const { test, describe } = require('node:test');
const assert = require('node:assert');

const ClaudeAdapter = require('../src/adapters/claude');

// `stderrBuf` used to be collected and never read: a CLI that died — or hung —
// before emitting its first JSON event left the only account of why in a string
// no code path looked at, and the channel got "No response generated". These
// pin the two properties that make it reportable: it comes back at all, and it
// comes back with credentials stripped, since a CLI failing on auth tends to
// echo the key it was handed.

const tail = (stderrBuf) =>
ClaudeAdapter.prototype._stderrTail.call(null, { stderrBuf });

describe('ClaudeAdapter._stderrTail', () => {
test('returns the process stderr so a silent failure has something to report', () => {
assert.strictEqual(
tail(' error: --input-format requires --output-format stream-json\n'),
'error: --input-format requires --output-format stream-json',
);
});

test('redacts credentials the CLI echoed back', () => {
const out = tail('Invalid API key: sk-Mzax7abcdef123456');
assert.ok(!out.includes('sk-Mzax7abcdef123456'), out);
assert.match(out, /sk-\[REDACTED\]/);
});

test('reads as empty when the process said nothing, rather than as a blank error', () => {
assert.strictEqual(tail(''), '');
assert.strictEqual(tail(' \n '), '');
assert.strictEqual(tail(undefined), '');
assert.strictEqual(ClaudeAdapter.prototype._stderrTail.call(null, null), '');
});

test('keeps the END of a long stream — the failure is the last thing said', () => {
const noise = Array.from({ length: 200 }, (_, i) => `warn ${i}: deprecated flag`).join('\n');
const out = tail(`${noise}\nfatal: could not connect to the model endpoint`);
assert.ok(out.endsWith('fatal: could not connect to the model endpoint'), out.slice(-60));
assert.ok(out.length <= 800, String(out.length));
});

// Redaction runs over the WHOLE buffer before the tail is cut: slicing first
// could split a key so the pattern no longer matches and a fragment survives.
test('redacts across the part that gets cut away, not just the tail', () => {
const out = tail(`Authorization: Bearer sk-Mzax7abcdef123456\n${'filler line\n'.repeat(200)}done`);
assert.ok(!out.includes('Mzax7abcdef'), out);
});
});
12 changes: 10 additions & 2 deletions packages/launcher/e2e/respond.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ const spec = agentBySlug(SLUG)

const INSTALL_TIMEOUT = 15 * 60 * 1000
const START_TIMEOUT = 90_000
// Longer than any adapter's own give-up timer, which is 300s across the board:
// claude's stdout watchdog (20 x 15s), codex's direct-LLM request timeout,
// opencode's TIMEOUT_MS, gemini's idle monitor. Polling for the same 300s was a
// dead heat — the adapter posts its diagnosis to the channel at the exact
// moment this stops listening, so every failure reported as the contentless
// "No agent reply within 300s" while the real reason was one second away.
// Waiting past it makes that message the assertion's own failure text.
const REPLY_TIMEOUT = 360_000

// Per-agent credentials (keys from E2E_* secrets; all via one gateway). claude
// speaks Anthropic (own base); the rest are OpenAI-compatible on E2E_OPENAI_BASE.
Expand Down Expand Up @@ -94,7 +102,7 @@ test.describe("launcher full flow", () => {
// keys can't drive it, so its keyed flow stays install-smoke-only for now.
test.skip(SLUG === "cursor", "cursor: needs a real cursor.com API key (no gateway support)")
test.skip(!haveAgentKey(), `no provider API key for ${SLUG}`)
test.setTimeout(INSTALL_TIMEOUT + 12 * 60 * 1000)
test.setTimeout(INSTALL_TIMEOUT + 13 * 60 * 1000)

const runId = process.env.GITHUB_RUN_ID || String(Date.now())
const osTag =
Expand Down Expand Up @@ -371,7 +379,7 @@ test.describe("launcher full flow", () => {
const cursor = await baselineCursor()
await sendMessage(name, name, "What is 2+2? Reply with just the number.")
try {
const reply = await pollForReply(name, name, cursor, 300_000)
const reply = await pollForReply(name, name, cursor, REPLY_TIMEOUT)
expect(reply).toContain("4")
} catch (e) {
// Attach the daemon log/status so a non-reply is diagnosable (why the
Expand Down
Loading