feat(cli): add openclaw triage for sanitized agent debugging handoffs (#128756)

* feat(cli): add openclaw triage for sanitized agent debugging handoffs

Collects read-only doctor findings through a new collectDoctorFindings seam,
reuses the sanitized diagnostics export, writes a bounded 8 KiB debugging
prompt, and prints Claude Code / Codex / embedded agent handoff commands.
Embedded --run gates on an interactive terminal plus a live inference probe.

* fix(doctor): keep word separation when scrubbing multi-line errors

scrubDoctorErrorMessage dropped newlines without substituting a space, so
multi-line errors rendered with glued words in doctor and triage output.

* feat(cli): hand triage prompts straight to a detected coding agent

Interactive triage now detects installed agents via resolveExecutablePath,
offers embedded/Claude Code/Codex/print in a picker, and spawns the chosen
binary with the prompt, propagating its exit code. Embedded inference is
probed only after selection. JSON output adds detectedAgents.

* fix(cli): redact local paths in triage prompts and drop unlaunchable targets

Prompt content now uses canonical path-aware redaction (redactSupportString),
so paths reach external agents as ~/... or $OPENCLAW_STATE_DIR/... instead of
absolute home paths; operator-facing JSON paths and printed commands stay real.
Findings are fitted to the byte budget left after the trailing sections so the
omission notice, bundle path, and privacy statement always survive truncation.
Windows command shims are listed as manual commands rather than offered as a
direct launch that shell-less spawn rejects.

* fix(cli): clarify which Node runtime triage reports

A live agent run flagged the reported version as wrong because the shell
default differed from the runtime executing the CLI.

* docs: route new installs to openclaw triage when setup fails

Getting Started had no recovery path; add one that leads with triage, and
document prompt location, environment inheritance, and exit codes on the
CLI page. Keep the Triage page title untranslated like Doctor.
This commit is contained in:
Peter Steinberger
2026-08-25 00:12:32 -07:00
committed by GitHub
parent 02a5be3892
commit 091f23ff49
19 changed files with 1090 additions and 7 deletions
+4
View File
@@ -667,6 +667,10 @@
"source": "Doctor",
"target": "Doctor"
},
{
"source": "Triage",
"target": "Triage"
},
{
"source": "Config",
"target": "配置"
+2 -1
View File
@@ -24,7 +24,7 @@ Setup commands by intent:
| Setup and onboarding | [`openclaw`](/cli/openclaw) · [`setup`](/cli/setup) · [`onboard`](/cli/onboard) · [`configure`](/cli/configure) · [`config`](/cli/config) · [`completion`](/cli/completion) · [`doctor`](/cli/doctor) · [`dashboard`](/cli/dashboard) |
| Reset, backup, and migration | [`backup`](/cli/backup) · [`migrate`](/cli/migrate) · [`reset`](/cli/reset) · [`uninstall`](/cli/uninstall) · [`update`](/cli/update) |
| Messaging and agents | [`message`](/cli/message) · [`agent`](/cli/agent) · [`agents`](/cli/agents) · [`attach`](/cli/attach) · [`acp`](/cli/acp) · [`mcp`](/cli/mcp) |
| Health and sessions | [`status`](/cli/status) · [`health`](/cli/health) · [`sessions`](/cli/sessions) · [`resume`](/cli/resume) · [`audit`](/cli/audit) |
| Health and sessions | [`status`](/cli/status) · [`health`](/cli/health) · [`triage`](/cli/triage) · [`sessions`](/cli/sessions) · [`resume`](/cli/resume) · [`audit`](/cli/audit) |
| Gateway and logs | [`gateway`](/cli/gateway) · [`logs`](/cli/logs) · [`system`](/cli/system) |
| Models and inference | [`models`](/cli/models) · [`promos`](/cli/promos) · [`infer`](/cli/infer) · `capability` (alias for [`infer`](/cli/infer)) · [`memory`](/cli/memory) · [`wiki`](/cli/wiki) |
| Network and nodes | [`directory`](/cli/directory) · [`nodes`](/cli/nodes) · [`devices`](/cli/devices) · [`node`](/cli/node) · [`worker`](/cli/worker) |
@@ -125,6 +125,7 @@ openclaw [--dev] [--profile <name>] <command>
validate
completion
doctor
triage
dashboard
backup
create
+55
View File
@@ -0,0 +1,55 @@
---
summary: "CLI reference for `openclaw triage` (sanitized diagnostics and agent handoff)"
read_when:
- OpenClaw is misbehaving and you want an agent-ready debugging prompt
- You need a sanitized diagnostics bundle without applying repairs
title: "Triage"
---
# `openclaw triage`
Run read-only Doctor checks, collect the existing sanitized diagnostics archive, and write a bounded Markdown prompt for an agent debugging this OpenClaw installation.
```bash
openclaw triage
```
The prompt includes the OpenClaw version, platform, Node.js version, prioritized Doctor findings with repair hints, and the diagnostics archive path. The archive contains sanitized config, Gateway status and health snapshots, operational log summaries, and available stability diagnostics. If the Gateway is unreachable, triage still writes the prompt and explains why the archive is unavailable.
Secrets, tokens, raw chat payloads, and raw logs are excluded. Paths inside the prompt are shown relative to `~` or `$OPENCLAW_STATE_DIR`; the saved prompt path, archive path, and printed handoff commands retain the real absolute paths needed by your shell. Doctor checks remain advisory and do not apply repairs.
## Agent handoff
In an interactive terminal, triage detects the agent handoff routes available on the current machine and asks which one to use. A configured OpenClaw embedded agent appears first, followed by Claude Code when `claude` is on `PATH`, Codex CLI when `codex` is on `PATH`, and an option to just print the commands.
Choosing Claude Code or Codex starts its interactive session directly with the generated prompt. Choosing the embedded agent first verifies the configured model with a live inference check, then runs one OpenClaw agent turn. `--run` requests that same verified embedded route explicitly.
On Windows, agents installed only as `.cmd` or `.bat` command shims appear in the manual handoff commands instead of the direct-launch picker.
Non-interactive sessions and the print-only choice provide these manual handoff commands instead:
```bash
claude "$(cat '<prompt-path>')"
codex exec - < '<prompt-path>'
openclaw triage --run
```
JSON output also includes `detectedAgents`, listing the external agents found on `PATH`. JSON output and non-interactive sessions never start an agent.
## Output and exit codes
The prompt is written to `logs/support/` inside the state directory with owner-only permissions, alongside the diagnostics archive when one was produced. Both paths are printed, and `--json` returns them plus finding counts by severity.
A launched agent inherits the current environment, so it inspects the same installation the prompt describes, including a custom `OPENCLAW_STATE_DIR`. Triage exits with the launched agent's exit code. If the agent cannot be started, triage prints its manual command and exits non-zero. Selecting the embedded agent when no model is configured reports the missing model and exits non-zero without starting a turn.
## Options
| Option | Effect |
| ------------- | -------------------------------------------------------------------------------- |
| `--json` | Emit prompt and archive paths, finding counts, detected agents, and commands. |
| `--no-export` | Skip the diagnostics archive and only generate the debugging prompt. |
| `--run` | Run one embedded agent turn after checking the model in an interactive terminal. |
`--json` cannot be combined with `--run`.
Related: [Doctor](/cli/doctor), [Gateway](/cli/gateway), and [Troubleshooting](/help/troubleshooting).
+1
View File
@@ -1830,6 +1830,7 @@
"cli/security",
"cli/setup",
"cli/status",
"cli/triage",
"cli/uninstall",
"cli/update"
]
+2
View File
@@ -13,6 +13,7 @@ Triage front door. 2 minutes to a diagnosis, then jump to the deep page.
Run this ladder in order:
```bash
openclaw triage
openclaw status
openclaw status --all
openclaw gateway probe
@@ -24,6 +25,7 @@ openclaw logs --follow
Good output, one line each:
- `openclaw triage` writes a sanitized, agent-ready diagnosis and, when the Gateway is reachable, a support archive. See [Triage](/cli/triage) for agent handoff options.
- `openclaw status` shows configured channels, no auth errors.
- `openclaw status --all` produces a full, shareable report.
- `openclaw gateway probe` shows `Reachable: yes`. `Capability: ...` is the
+16
View File
@@ -118,6 +118,20 @@ openclaw dashboard
</Accordion>
## If setup does not work
One command turns the current state of your install into a diagnosis you can act on:
```bash
openclaw triage
```
It runs read-only health checks, writes a sanitized prompt describing what it found, and then offers to hand that prompt to a coding agent it detects on your machine — Claude Code, Codex CLI, or the built-in OpenClaw agent — so the agent starts with the diagnosis already loaded. Pick "just print the commands" if you would rather run the handoff yourself.
Nothing leaves your machine until you choose an agent, and secrets, tokens, raw chat payloads, and raw logs are excluded from the prompt.
To read the findings yourself instead, run [`openclaw doctor`](/cli/doctor). For symptom-first routes, see [Troubleshooting](/help/troubleshooting).
## What to do next
<Columns>
@@ -150,3 +164,5 @@ Full reference: [Environment variables](/help/environment).
- [Install overview](/install)
- [Channels overview](/channels)
- [Setup](/start/setup)
- [Triage](/cli/triage)
- [Troubleshooting](/help/troubleshooting)
+4
View File
@@ -429,6 +429,10 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
networkProxy: ({ argv }) => (hasCliOption(argv, "--state-sqlite") ? "bypass" : "default"),
},
},
{
commandPath: ["triage"],
policy: { configGuard: "skip", loadPlugins: "never" },
},
{ commandPath: ["exec-approvals"], policy: { networkProxy: "bypass" } },
{ commandPath: ["exec-policy"], policy: { networkProxy: "bypass" } },
{ commandPath: ["hooks"], policy: { networkProxy: "bypass" } },
+1
View File
@@ -36,6 +36,7 @@ describe("command-startup-policy", () => {
["uninstall"],
["agent", "exec"],
["status"],
["triage"],
["agents", "bindings"],
["approvals", "pending"],
["skills"],
+1 -1
View File
@@ -90,7 +90,7 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec<
exportName: "registerAuditCommand",
},
{
commandNames: ["doctor", "dashboard", "reset", "uninstall"],
commandNames: ["doctor", "triage", "dashboard", "reset", "uninstall"],
loadModule: () => import("./register.maintenance.js"),
exportName: "registerMaintenanceCommands",
},
+3 -1
View File
@@ -27,6 +27,7 @@ vi.mock("./register.backup.js", () => ({
vi.mock("./register.maintenance.js", () => ({
registerMaintenanceCommands: (program: Command) => {
program.command("doctor");
program.command("triage");
program.command("dashboard");
program.command("reset");
program.command("uninstall");
@@ -152,6 +153,7 @@ describe("command-registry", () => {
const names = namesOf(program);
expect(names).toContain("doctor");
expect(names).toContain("triage");
expect(names).toContain("status");
expect(names.length).toBeGreaterThan(1);
});
@@ -204,6 +206,6 @@ describe("command-registry", () => {
const found = await registerCoreCliByName(program, testProgramContext, "dashboard");
expect(found).toBe(true);
expect(namesOf(program)).toEqual(["doctor", "dashboard", "reset", "uninstall"]);
expect(namesOf(program)).toEqual(["doctor", "triage", "dashboard", "reset", "uninstall"]);
});
});
@@ -2,6 +2,7 @@
import { isExperimentalClawsEnabled } from "../../claws/experimental.js";
import { isConfigMachineOutput } from "../config-output-mode.js";
import { isDoctorMachineOutput } from "../doctor-output-mode.js";
import { hasMachineOutputOption } from "../machine-output-argv.js";
import { defineCommandDescriptorCatalog } from "./command-descriptor-utils.js";
import type { NamedCommandDescriptor } from "./command-group-descriptors.js";
@@ -65,6 +66,12 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
hasSubcommands: false,
machineOutput: isDoctorMachineOutput,
},
{
name: "triage",
description: "Collect sanitized diagnostics and prepare an agent debugging handoff",
hasSubcommands: false,
machineOutput: ({ argv }) => hasMachineOutputOption(argv, "--json"),
},
{
name: "dashboard",
description: "Open the Control UI with your current token",
@@ -5,6 +5,7 @@ import { registerMaintenanceCommands } from "./register.maintenance.js";
const mocks = vi.hoisted(() => ({
doctorCommand: vi.fn(),
triageCommand: vi.fn(),
dashboardCommand: vi.fn(),
resetCommand: vi.fn(),
uninstallCommand: vi.fn(),
@@ -19,6 +20,7 @@ const mocks = vi.hoisted(() => ({
const {
doctorCommand,
triageCommand,
dashboardCommand,
resetCommand,
uninstallCommand,
@@ -48,6 +50,10 @@ vi.mock("../../commands/doctor.js", () => ({
doctorCommand: mocks.doctorCommand,
}));
vi.mock("../../commands/triage.js", () => ({
triageCommand: mocks.triageCommand,
}));
vi.mock("../../commands/dashboard.js", () => ({
dashboardCommand: mocks.dashboardCommand,
}));
@@ -602,6 +608,28 @@ describe("registerMaintenanceCommands doctor action", () => {
expect(options.json).toBe(true);
});
it.each([
{ args: [], options: { json: false, noExport: false, run: false } },
{ args: ["--json", "--no-export"], options: { json: true, noExport: true, run: false } },
{ args: ["--run"], options: { json: false, noExport: false, run: true } },
])("forwards triage options for $args", async ({ args, options }) => {
triageCommand.mockResolvedValue(undefined);
await runMaintenanceCli(["triage", ...args]);
expect(triageCommand).toHaveBeenCalledWith(runtime, options);
});
it("rejects embedded execution in triage JSON mode", async () => {
await runMaintenanceCli(["triage", "--json", "--run"]);
expect(triageCommand).not.toHaveBeenCalled();
expect(runtime.writeJson).toHaveBeenCalledWith(
jsonFailure("triage --json cannot be combined with --run."),
);
expect(runtime.exit).toHaveBeenCalledWith(2);
});
it("passes reset options to reset command", async () => {
resetCommand.mockResolvedValue(undefined);
+30 -1
View File
@@ -1,4 +1,4 @@
// Maintenance command registration: doctor, dashboard, reset, and uninstall.
// Maintenance command registration: doctor, triage, dashboard, reset, and uninstall.
import type { Command } from "commander";
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
@@ -208,6 +208,35 @@ export function registerMaintenanceCommands(program: Command) {
});
setCommandJsonMode(doctor, "output", isDoctorMachineOutput);
program
.command("triage")
.description("Collect sanitized diagnostics and prepare an agent debugging handoff")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/triage", "docs.openclaw.ai/cli/triage")}\n`,
)
.option("--json", "Output sanitized handoff paths, finding counts, and commands as JSON", false)
.option("--no-export", "Skip the sanitized diagnostics archive")
.option("--run", "Run one embedded agent turn after verifying model inference", false)
.action(async (opts) => {
if (opts.json === true && opts.run === true) {
return exitDoctorError("triage --json cannot be combined with --run.", true);
}
await runCommandWithRuntime(
defaultRuntime,
async () => {
const { triageCommand } = await import("../../commands/triage.js");
await triageCommand(defaultRuntime, {
json: opts.json === true,
noExport: opts.export === false,
run: opts.run === true,
});
},
opts.json ? (err: unknown) => exitDoctorError(formatError(err), true) : undefined,
);
});
program
.command("dashboard")
.description("Open the Control UI with your current token")
+22 -2
View File
@@ -54,6 +54,7 @@ type DoctorLintStateView = {
type DoctorLintExecution = {
exitCode: number;
findings: readonly HealthFinding[];
writeOutput: () => void;
};
@@ -84,6 +85,23 @@ export async function runDoctorLintCli(
runtime: RuntimeEnv,
opts: DoctorLintCliOptions,
): Promise<number> {
const execution = await prepareDoctorLintExecution(runtime, opts);
execution.writeOutput();
return execution.exitCode;
}
/** Collect advisory doctor findings without writing output or repairing operator state. */
export async function collectDoctorFindings(
runtime: RuntimeEnv,
): Promise<readonly HealthFinding[]> {
const execution = await prepareDoctorLintExecution(runtime, { severityMin: "info" });
return execution.findings;
}
async function prepareDoctorLintExecution(
runtime: RuntimeEnv,
opts: DoctorLintCliOptions,
): Promise<DoctorLintExecution> {
const sevMin =
opts.severityMin === undefined ? "warning" : parseHealthFindingSeverity(opts.severityMin);
if (sevMin === null) {
@@ -139,8 +157,7 @@ export async function runDoctorLintCli(
execution = createStateSnapshotFailureExecution(runtime, opts, sevMin, error);
}
}
execution.writeOutput();
return execution.exitCode;
return execution;
}
async function executeDoctorLint(
@@ -155,6 +172,7 @@ async function executeDoctorLint(
const visible = findings.filter((finding) => healthFindingMeetsSeverity(finding, sevMin));
return {
exitCode: exitCodeFromFindings(findings, sevMin),
findings: visible,
writeOutput() {
if (detectMode(opts) === "json") {
writeJsonResult({
@@ -215,6 +233,7 @@ async function executeDoctorLint(
const exitCode = exitCodeFromFindings(result.findings, sevMin);
return {
exitCode,
findings: visible,
writeOutput() {
const mode = detectMode(opts);
if (mode === "json") {
@@ -326,6 +345,7 @@ function createStateSnapshotFailureExecution(
const visible = healthFindingMeetsSeverity(finding, sevMin) ? [finding] : [];
return {
exitCode: exitCodeFromFindings([finding], sevMin),
findings: visible,
writeOutput() {
if (detectMode(opts) === "json") {
writeJsonResult({
+123
View File
@@ -0,0 +1,123 @@
// Render bounded, sanitized doctor findings into a fixing-agent handoff prompt.
import { HEALTH_FINDING_SEVERITY_RANK, type HealthFinding } from "../flows/health-checks.js";
import {
redactSupportString,
type SupportRedactionContext,
} from "../logging/diagnostic-support-redaction.js";
import { truncateUtf8Prefix } from "../utils/utf8-truncate.js";
import { VERSION } from "../version.js";
const TRIAGE_PROMPT_MAX_BYTES = 8 * 1024;
const TRIAGE_FINDINGS_MAX_COUNT = 10;
// Per-field caps keep one noisy finding from crowding the prompt; the whole-prompt
// byte cap below is the real bound, so these stay generous enough to keep fix hints usable.
const TRIAGE_FINDING_MAX_LENGTHS = { id: 100, message: 320, hint: 180 };
// Worst-case bytes for the "N more findings omitted" notice, reserved up front so the
// notice always fits once at least one finding has been rendered.
const OMISSION_RESERVE = 96;
export type TriageBundle =
| { kind: "available"; path: string }
| { kind: "unavailable"; reason: string }
| { kind: "skipped" };
function promptByteLength(lines: readonly string[]): number {
return Buffer.byteLength(lines.join("\n"), "utf8") + 1;
}
function renderTriageTail(bundle: TriageBundle, redaction: SupportRedactionContext): string[] {
const lines = ["", "## Diagnostics bundle", ""];
if (bundle.kind === "available") {
lines.push(
`Sanitized ZIP: ${redactSupportString(bundle.path, redaction)}`,
"Contains sanitized config, status and health snapshots, operational log summaries, and available payload-free stability diagnostics.",
);
} else if (bundle.kind === "unavailable") {
lines.push(`Diagnostics export unavailable: ${redactSupportString(bundle.reason, redaction)}`);
} else {
lines.push("Diagnostics export skipped with `--no-export`.");
}
return [
...lines,
"",
"## Privacy",
"",
"Secrets, tokens, raw chat payloads, and raw logs are excluded; local paths are relative to `~` or `$OPENCLAW_STATE_DIR`.",
"",
];
}
/** Render a bounded fixing-agent prompt from already-sanitized doctor findings. */
export function renderTriagePrompt(params: {
findings: readonly HealthFinding[];
bundle: TriageBundle;
redaction: SupportRedactionContext;
}): string {
const { bundle, redaction } = params;
const findings = params.findings.toSorted((left, right) => {
const severity =
HEALTH_FINDING_SEVERITY_RANK[right.severity] - HEALTH_FINDING_SEVERITY_RANK[left.severity];
return severity || left.checkId.localeCompare(right.checkId);
});
const lines = [
"You are debugging THIS machine's OpenClaw installation. Identify the root cause, explain the safest repair, and verify the result. You may run `openclaw doctor`, `openclaw doctor --fix`, `openclaw status --all`, and `openclaw logs`. Product documentation: https://docs.openclaw.ai.",
"",
"## Environment",
"",
`- OpenClaw: ${VERSION}`,
`- Platform: ${process.platform}`,
`- Node.js: ${process.versions.node} (the runtime executing OpenClaw, which may differ from the shell default)`,
"",
"## Doctor findings",
"",
];
if (findings.length === 0) {
lines.push("No advisory doctor findings were reported.");
}
// Findings are the only unbounded input, so they are fitted against the byte budget
// left over after the trailing sections. That keeps the omission notice, bundle path,
// and privacy statement in the prompt instead of losing them to tail truncation.
const tail = renderTriageTail(bundle, redaction);
const findingsBudget =
TRIAGE_PROMPT_MAX_BYTES - promptByteLength(lines) - promptByteLength(tail) - OMISSION_RESERVE;
let used = 0;
let rendered = 0;
for (const finding of findings.slice(0, TRIAGE_FINDINGS_MAX_COUNT)) {
const id = redactSupportString(finding.checkId, redaction, {
maxLength: TRIAGE_FINDING_MAX_LENGTHS.id,
});
const text = redactSupportString(finding.message, redaction, {
maxLength: TRIAGE_FINDING_MAX_LENGTHS.message,
});
const entry = [`- [${finding.severity}] ${id}: ${text}`];
if (finding.fixHint) {
const hint = redactSupportString(finding.fixHint, redaction, {
maxLength: TRIAGE_FINDING_MAX_LENGTHS.hint,
});
entry.push(` Fix: ${hint}`);
}
const entryBytes = promptByteLength(entry);
if (rendered > 0 && used + entryBytes > findingsBudget) {
break;
}
lines.push(...entry);
used += entryBytes;
rendered += 1;
}
const omitted = findings.length - rendered;
if (omitted > 0) {
lines.push(`${omitted} more findings omitted; run \`openclaw doctor\` for the full list.`);
}
lines.push(...tail);
const prompt = lines.map((line) => line.replace(/[\r\n]+/gu, " ").trimEnd()).join("\n");
if (Buffer.byteLength(prompt, "utf8") <= TRIAGE_PROMPT_MAX_BYTES) {
return prompt;
}
// Keep the model-visible artifact bounded even if a plugin emits unusually large metadata.
const suffix = "\n[Prompt truncated to the 8 KiB safety limit.]\n";
return `${truncateUtf8Prefix(prompt, TRIAGE_PROMPT_MAX_BYTES - Buffer.byteLength(suffix))}${suffix}`;
}
+546
View File
@@ -0,0 +1,546 @@
// Triage tests protect bounded prompts, sanitized handoffs, and embedded-run gating.
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { HealthFinding } from "../flows/health-checks.js";
import { renderTriagePrompt } from "./triage-prompt.js";
import { triageCommand } from "./triage.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const mocks = vi.hoisted(() => ({
collectDoctorFindings: vi.fn(),
callGatewayFromCliWithTransport: vi.fn(),
writeDiagnosticSupportExport: vi.fn(),
gatherDaemonStatus: vi.fn(),
verifySetupInference: vi.fn(),
agentExecCommand: vi.fn(),
readConfigFileSnapshot: vi.fn(),
resolveExecutablePath: vi.fn(),
select: vi.fn(),
spawn: vi.fn(),
}));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
spawn: mocks.spawn,
}));
vi.mock("./doctor-lint.js", () => ({
collectDoctorFindings: mocks.collectDoctorFindings,
}));
vi.mock("../config/config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../config/config.js")>()),
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
}));
vi.mock("../infra/executable-path.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../infra/executable-path.js")>()),
resolveExecutablePath: mocks.resolveExecutablePath,
}));
vi.mock("./configure.shared.js", () => ({
select: mocks.select,
}));
vi.mock("../cli/gateway-rpc.js", () => ({
callGatewayFromCliWithTransport: mocks.callGatewayFromCliWithTransport,
}));
vi.mock("../logging/diagnostic-support-export.js", () => ({
writeDiagnosticSupportExport: mocks.writeDiagnosticSupportExport,
}));
vi.mock("../cli/daemon-cli/status.gather.js", () => ({
gatherDaemonStatus: mocks.gatherDaemonStatus,
}));
vi.mock("../system-agent/setup-inference.js", () => ({
verifySetupInference: mocks.verifySetupInference,
}));
vi.mock("./agent-exec.js", () => ({
agentExecCommand: mocks.agentExecCommand,
}));
function createRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
writeStdout: vi.fn(),
writeJson: vi.fn(),
};
}
async function withInteractiveTerminal(run: () => Promise<void>): Promise<void> {
const descriptors = [process.stdin, process.stdout].map((stream) =>
Object.getOwnPropertyDescriptor(stream, "isTTY"),
);
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
try {
await run();
} finally {
for (const [index, stream] of [process.stdin, process.stdout].entries()) {
const descriptor = descriptors[index];
if (descriptor) {
Object.defineProperty(stream, "isTTY", descriptor);
} else {
Reflect.deleteProperty(stream, "isTTY");
}
}
}
}
describe("renderTriagePrompt", () => {
const homeDir = "/home/triage-test";
const redaction = {
env: { HOME: homeDir },
stateDir: `${homeDir}/.openclaw`,
};
it("orders sanitized findings by severity and includes repair hints and bundle details", () => {
const findings: HealthFinding[] = [
{ checkId: "core/info", severity: "info", message: "informational" },
{ checkId: "core/warning", severity: "warning", message: "needs attention" },
{
checkId: "core/error",
severity: "error",
message: "model routing failed",
fixHint: "Run `openclaw doctor --fix`.",
},
];
const prompt = renderTriagePrompt({
findings,
bundle: { kind: "available", path: `${redaction.stateDir}/diagnostics.zip` },
redaction,
});
expect(prompt.indexOf("[error]")).toBeLessThan(prompt.indexOf("[warning]"));
expect(prompt.indexOf("[warning]")).toBeLessThan(prompt.indexOf("[info]"));
expect(prompt).toContain("Fix: Run `openclaw doctor --fix`.");
expect(prompt).toContain("Sanitized ZIP: $OPENCLAW_STATE_DIR/diagnostics.zip");
expect(prompt).toContain("Secrets, tokens, raw chat payloads, and raw logs are excluded");
});
it("redacts home and state paths across finding fields and diagnostics handoffs", () => {
const prompt = renderTriagePrompt({
findings: [
{
checkId: `${homeDir}/checks/config`,
severity: "error",
message: `Config: ${redaction.stateDir}/openclaw.json\nneeds repair`,
fixHint: `Inspect ${homeDir}/logs/gateway.log`,
},
],
bundle: { kind: "available", path: `${homeDir}/Downloads/diagnostics.zip` },
redaction,
});
expect(prompt).toContain(
"[error] ~/checks/config: Config: $OPENCLAW_STATE_DIR/openclaw.json needs repair",
);
expect(prompt).toContain("Fix: Inspect ~/logs/gateway.log");
expect(prompt).toContain("Sanitized ZIP: ~/Downloads/diagnostics.zip");
expect(prompt).not.toContain(homeDir);
});
it("hard-bounds multibyte findings and explicitly reports omitted findings", () => {
const findings: HealthFinding[] = Array.from({ length: 25 }, (_, index) => ({
checkId: `core/check-${index}`,
severity: "warning",
message: "🦞".repeat(4_000),
fixHint: "修".repeat(4_000),
}));
const prompt = renderTriagePrompt({ findings, bundle: { kind: "skipped" }, redaction });
expect(Buffer.byteLength(prompt, "utf8")).toBeLessThanOrEqual(8 * 1024);
// Every finding is either rendered or explicitly counted as omitted, and the
// trailing sections survive because findings are fitted to the byte budget.
const rendered = prompt.match(/^- \[warning\]/gmu)?.length ?? 0;
expect(rendered).toBeGreaterThan(0);
expect(prompt).toContain(
`${findings.length - rendered} more findings omitted; run \`openclaw doctor\` for the full list.`,
);
expect(prompt).toContain("## Privacy");
expect(prompt).not.toContain("\uFFFD");
expect(prompt).toContain("...");
});
it.each([
{
bundle: { kind: "unavailable" as const, reason: "Gateway unreachable" },
text: "Diagnostics export unavailable: Gateway unreachable",
},
{
bundle: {
kind: "unavailable" as const,
reason: `Gateway config: ${redaction.stateDir}/openclaw.json`,
},
text: "Diagnostics export unavailable: Gateway config: $OPENCLAW_STATE_DIR/openclaw.json",
},
{
bundle: { kind: "skipped" as const },
text: "Diagnostics export skipped with `--no-export`.",
},
])("explains absent diagnostics archives: $text", ({ bundle, text }) => {
expect(renderTriagePrompt({ findings: [], bundle, redaction })).toContain(text);
});
});
describe("triageCommand", () => {
let stateDir: string;
beforeEach(() => {
vi.clearAllMocks();
stateDir = tempDirs.make("openclaw-triage-test-");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
mocks.collectDoctorFindings.mockResolvedValue([]);
mocks.readConfigFileSnapshot.mockResolvedValue({ exists: false, valid: true, config: {} });
mocks.resolveExecutablePath.mockReturnValue(undefined);
mocks.select.mockResolvedValue({ kind: "print" });
mocks.spawn.mockImplementation(() => {
const child = new EventEmitter();
queueMicrotask(() => child.emit("exit", 0, null));
return child;
});
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("writes one stable JSON handoff without probing inference or starting an agent", async () => {
const findings: HealthFinding[] = [
{ checkId: "core/error", severity: "error", message: "broken" },
{ checkId: "core/warning", severity: "warning", message: "warn" },
{ checkId: "core/info", severity: "info", message: "detail" },
];
mocks.collectDoctorFindings.mockResolvedValue(findings);
const runtime = createRuntime();
await triageCommand(runtime, { json: true, noExport: true });
const promptPath = runtime.writeJson.mock.calls[0]?.[0]?.promptPath as string;
expect(runtime.writeJson).toHaveBeenCalledOnce();
expect(path.isAbsolute(promptPath)).toBe(true);
expect(promptPath.startsWith(stateDir)).toBe(true);
expect(runtime.writeJson.mock.calls[0]?.[0]).toEqual({
promptPath,
bundlePath: null,
bundleError: null,
findings: { error: 1, warning: 1, info: 1 },
detectedAgents: [],
suggestedCommands: [
`claude "$(cat '${promptPath}')"`,
`codex exec - < '${promptPath}'`,
"openclaw triage --run",
],
});
expect(await fs.readFile(promptPath, "utf8")).toContain("[error] core/error: broken");
expect(mocks.callGatewayFromCliWithTransport).not.toHaveBeenCalled();
expect(mocks.verifySetupInference).not.toHaveBeenCalled();
expect(mocks.agentExecCommand).not.toHaveBeenCalled();
});
it("reports only external agents resolved on PATH without checking their credentials", async () => {
mocks.resolveExecutablePath.mockImplementation((binary: string) =>
binary === "codex" ? "/usr/local/bin/codex" : undefined,
);
const runtime = createRuntime();
await triageCommand(runtime, { json: true, noExport: true });
expect(runtime.writeJson.mock.calls[0]?.[0]).toMatchObject({ detectedAgents: ["codex"] });
expect(mocks.resolveExecutablePath.mock.calls).toEqual([["claude"], ["codex"]]);
expect(mocks.readConfigFileSnapshot).not.toHaveBeenCalled();
expect(mocks.verifySetupInference).not.toHaveBeenCalled();
});
it("degrades to a sanitized prompt when the Gateway cannot provide diagnostics", async () => {
const secret = "sk-abcdefghijklmnopqrstuvwxyz123456";
mocks.callGatewayFromCliWithTransport.mockRejectedValue(
new Error(
`Gateway unreachable: Config: ${stateDir}/openclaw.json; Authorization: Bearer ${secret}`,
),
);
const runtime = createRuntime();
await triageCommand(runtime, { json: true });
const report = runtime.writeJson.mock.calls[0]?.[0] as {
promptPath: string;
bundlePath: null;
bundleError: string;
};
expect(report.bundlePath).toBeNull();
expect(report.bundleError).toContain("Gateway unreachable");
expect(report.bundleError).toContain("Config: $OPENCLAW_STATE_DIR/openclaw.json");
expect(report.bundleError).not.toContain(secret);
const prompt = await fs.readFile(report.promptPath, "utf8");
expect(prompt).toContain("Diagnostics export unavailable: Gateway unreachable");
expect(prompt).toContain("Config: $OPENCLAW_STATE_DIR/openclaw.json");
expect(prompt).not.toContain(stateDir);
expect(mocks.writeDiagnosticSupportExport).not.toHaveBeenCalled();
});
it("reuses the sanitized support exporter with Gateway status and health snapshots", async () => {
const health = { ok: true };
const status = { gateway: { reachable: true } };
const bundlePath = path.join(stateDir, "diagnostics.zip");
mocks.callGatewayFromCliWithTransport.mockResolvedValue(health);
mocks.gatherDaemonStatus.mockResolvedValue(status);
mocks.writeDiagnosticSupportExport.mockImplementation(async (options) => {
expect(await options.readHealthSnapshot()).toBe(health);
expect(await options.readStatusSnapshot()).toBe(status);
return { path: bundlePath };
});
const runtime = createRuntime();
await triageCommand(runtime, { json: true });
const report = runtime.writeJson.mock.calls[0]?.[0] as {
promptPath: string;
bundlePath: string;
bundleError: null;
suggestedCommands: string[];
};
expect(report).toMatchObject({ bundlePath, bundleError: null });
expect(path.isAbsolute(report.promptPath)).toBe(true);
expect(path.isAbsolute(report.bundlePath)).toBe(true);
expect(report.suggestedCommands[0]).toContain(report.promptPath);
expect(report.suggestedCommands[1]).toContain(report.promptPath);
expect(await fs.readFile(report.promptPath, "utf8")).toContain(
"Sanitized ZIP: $OPENCLAW_STATE_DIR/diagnostics.zip",
);
expect(mocks.gatherDaemonStatus).toHaveBeenCalledWith({
rpc: { timeout: "3000", json: true },
probe: true,
requireRpc: false,
deep: false,
});
});
it("refuses embedded execution when the live inference probe fails", async () => {
mocks.verifySetupInference.mockResolvedValue({
ok: false,
status: "auth",
error: "The configured model is unavailable",
});
const runtime = createRuntime();
await withInteractiveTerminal(async () => {
await expect(triageCommand(runtime, { noExport: true, run: true })).rejects.toThrow(
"Run `openclaw onboard` or use a suggested handoff command.",
);
});
expect(mocks.verifySetupInference).toHaveBeenCalledWith({ runtime, timeoutMs: 15_000 });
expect(mocks.agentExecCommand).not.toHaveBeenCalled();
});
it("passes the saved prompt to one embedded agent turn after a healthy live probe", async () => {
mocks.verifySetupInference.mockResolvedValue({
ok: true,
modelRef: "openai/gpt-5.6-luna",
latencyMs: 12,
});
mocks.agentExecCommand.mockResolvedValue({ exitCode: 0 });
const runtime = createRuntime();
await withInteractiveTerminal(async () => {
await triageCommand(runtime, { noExport: true, run: true });
});
expect(mocks.agentExecCommand).toHaveBeenCalledExactlyOnceWith(
undefined,
{ messageFile: expect.stringMatching(/openclaw-triage-prompt-.*\.md$/u) },
runtime,
);
});
it("offers configured and installed agents in handoff order without probing before selection", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
config: { agents: { defaults: { model: "openai/gpt-5.6-luna" } } },
});
mocks.resolveExecutablePath.mockImplementation((binary: string) => `/usr/local/bin/${binary}`);
mocks.select.mockImplementation(async () => {
expect(mocks.verifySetupInference).not.toHaveBeenCalled();
return { kind: "print" };
});
const runtime = createRuntime();
await withInteractiveTerminal(async () => {
await triageCommand(runtime, { noExport: true });
});
expect(mocks.select).toHaveBeenCalledWith({
message: "Choose an agent to investigate this OpenClaw installation",
options: [
{ value: { kind: "embedded" }, label: "OpenClaw embedded agent" },
{
value: { kind: "external", agent: "claude", executablePath: "/usr/local/bin/claude" },
label: "Claude Code",
},
{
value: { kind: "external", agent: "codex", executablePath: "/usr/local/bin/codex" },
label: "Codex CLI",
},
{ value: { kind: "print" }, label: "Just print the commands" },
],
});
expect(runtime.log).toHaveBeenCalledWith("Ready-to-run agent handoffs:");
expect(mocks.spawn).not.toHaveBeenCalled();
expect(mocks.verifySetupInference).not.toHaveBeenCalled();
});
it("omits unavailable embedded and external agents from the interactive picker", async () => {
mocks.resolveExecutablePath.mockImplementation((binary: string) =>
binary === "codex" ? "/usr/local/bin/codex" : undefined,
);
const runtime = createRuntime();
await withInteractiveTerminal(async () => {
await triageCommand(runtime, { noExport: true });
});
expect(mocks.select.mock.calls[0]?.[0]?.options).toEqual([
{
value: { kind: "external", agent: "codex", executablePath: "/usr/local/bin/codex" },
label: "Codex CLI",
},
{ value: { kind: "print" }, label: "Just print the commands" },
]);
expect(mocks.verifySetupInference).not.toHaveBeenCalled();
});
it.each([
{ agent: "claude", executablePath: "C:\\tools\\claude.cmd" },
{ agent: "codex", executablePath: "C:\\tools\\codex.BAT" },
])(
"keeps Windows $agent command shims as manual-only handoffs",
async ({ agent, executablePath }) => {
const platform = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
mocks.resolveExecutablePath.mockImplementation((binary: string) =>
binary === agent ? executablePath : undefined,
);
const runtime = createRuntime();
try {
await withInteractiveTerminal(async () => {
await triageCommand(runtime, { noExport: true });
});
} finally {
platform.mockRestore();
}
expect(mocks.select.mock.calls[0]?.[0]?.options).toEqual([
{ value: { kind: "print" }, label: "Just print the commands" },
]);
expect(runtime.log).toHaveBeenCalledWith(
expect.stringMatching(new RegExp(`^ ${agent} `, "u")),
);
expect(mocks.spawn).not.toHaveBeenCalled();
},
);
it.each([
{ agent: "claude", exitCode: 0 },
{ agent: "codex", exitCode: 17 },
])("launches $agent interactively and propagates its exit code", async ({ agent, exitCode }) => {
mocks.resolveExecutablePath.mockImplementation((binary: string) =>
binary === agent ? `/usr/local/bin/${binary}` : undefined,
);
mocks.select.mockResolvedValue({
kind: "external",
agent,
executablePath: `/usr/local/bin/${agent}`,
});
mocks.spawn.mockImplementation(() => {
const child = new EventEmitter();
queueMicrotask(() => child.emit("exit", exitCode, null));
return child;
});
const runtime = createRuntime();
await withInteractiveTerminal(async () => {
await triageCommand(runtime, { noExport: true });
});
const promptLog = runtime.log.mock.calls[0]?.[0];
if (typeof promptLog !== "string") {
throw new Error("Expected triage to log the saved prompt path.");
}
const promptPath = promptLog.replace("Debugging prompt: ", "");
expect(mocks.spawn).toHaveBeenCalledExactlyOnceWith(
`/usr/local/bin/${agent}`,
[await fs.readFile(promptPath, "utf8")],
{ stdio: "inherit" },
);
expect(mocks.verifySetupInference).not.toHaveBeenCalled();
if (exitCode === 0) {
expect(runtime.exit).not.toHaveBeenCalled();
} else {
expect(runtime.exit).toHaveBeenCalledExactlyOnceWith(exitCode);
}
});
it("prints the selected manual command and exits nonzero when launching an agent fails", async () => {
mocks.resolveExecutablePath.mockImplementation((binary: string) =>
binary === "claude" ? "/usr/local/bin/claude" : undefined,
);
mocks.select.mockResolvedValue({
kind: "external",
agent: "claude",
executablePath: "/usr/local/bin/claude",
});
mocks.spawn.mockImplementation(() => {
const child = new EventEmitter();
queueMicrotask(() => child.emit("error", new Error("permission denied")));
return child;
});
const runtime = createRuntime();
await withInteractiveTerminal(async () => {
await triageCommand(runtime, { noExport: true });
});
expect(runtime.error).toHaveBeenCalledWith("Failed to launch claude: permission denied");
expect(runtime.log).toHaveBeenCalledWith(expect.stringMatching(/^Run manually: claude /u));
expect(runtime.exit).toHaveBeenCalledExactlyOnceWith(1);
});
it("probes inference only after the configured embedded agent is selected", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
config: { agents: { defaults: { model: "openai/gpt-5.6-luna" } } },
});
mocks.select.mockResolvedValue({ kind: "embedded" });
mocks.verifySetupInference.mockResolvedValue({
ok: true,
modelRef: "openai/gpt-5.6-luna",
latencyMs: 12,
});
mocks.agentExecCommand.mockResolvedValue({ exitCode: 0 });
const runtime = createRuntime();
await withInteractiveTerminal(async () => {
await triageCommand(runtime, { noExport: true });
});
expect(mocks.select.mock.invocationCallOrder[0]).toBeLessThan(
mocks.verifySetupInference.mock.invocationCallOrder[0]!,
);
expect(mocks.agentExecCommand).toHaveBeenCalledOnce();
expect(mocks.spawn).not.toHaveBeenCalled();
});
});
+220
View File
@@ -0,0 +1,220 @@
// Collect read-only doctor findings and sanitized diagnostics for an agent handoff.
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { tryResolveAmbientOwnerAgentId } from "../agents/agent-scope-config.js";
import { resolveAgentEffectiveModelPrimary } from "../agents/agent-scope.js";
import { callGatewayFromCliWithTransport } from "../cli/gateway-rpc.js";
import { resolveSubprocessExitCode } from "../cli/subprocess-exit-code.js";
import { readConfigFileSnapshot } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { scrubDoctorErrorMessage } from "../flows/doctor-error-message.js";
import type { HealthFindingSeverity } from "../flows/health-checks.js";
import { resolveExecutablePath } from "../infra/executable-path.js";
import { redactSupportString } from "../logging/diagnostic-support-redaction.js";
import { writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
import { select } from "./configure.shared.js";
import { renderTriagePrompt, type TriageBundle } from "./triage-prompt.js";
type TriageOptions = {
json?: boolean;
noExport?: boolean;
run?: boolean;
};
type TriageExternalAgent = "claude" | "codex";
type TriageHandoff =
| { kind: "print" }
| { kind: "embedded" }
| { kind: "external"; agent: TriageExternalAgent; executablePath: string };
type TriageHandoffMode = TriageHandoff | { kind: "offer" };
async function collectTriageBundle(skipExport: boolean): Promise<TriageBundle> {
if (skipExport) {
return { kind: "skipped" };
}
try {
const rpc = { timeout: "3000", json: true };
const health = await callGatewayFromCliWithTransport("health", rpc, undefined, {
defaultTimeoutMs: 3000,
sharedStateMode: "read-only",
});
const [{ writeDiagnosticSupportExport }, { gatherDaemonStatus }] = await Promise.all([
import("../logging/diagnostic-support-export.js"),
import("../cli/daemon-cli/status.gather.js"),
]);
const result = await writeDiagnosticSupportExport({
readHealthSnapshot: async () => health,
readStatusSnapshot: async () =>
await gatherDaemonStatus({ rpc, probe: true, requireRpc: false, deep: false }),
});
return { kind: "available", path: result.path };
} catch (error) {
return {
kind: "unavailable",
reason: scrubDoctorErrorMessage(error),
};
}
}
function resolveTriageHandoff(options: TriageOptions): TriageHandoffMode {
if (options.json === true) {
return { kind: "print" };
}
if (options.run === true) {
return { kind: "embedded" };
}
return process.stdin.isTTY && process.stdout.isTTY ? { kind: "offer" } : { kind: "print" };
}
function quoteShellArgument(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
/** Collect read-only diagnostics, write the bounded prompt, and optionally run one agent turn. */
export async function triageCommand(
runtime: RuntimeEnv,
options: TriageOptions = {},
): Promise<void> {
const { collectDoctorFindings } = await import("./doctor-lint.js");
const findings = await collectDoctorFindings(runtime);
const redaction = { env: process.env, stateDir: resolveStateDir() };
const bundle = await collectTriageBundle(options.noExport === true);
const prompt = renderTriagePrompt({ findings, bundle, redaction });
const now = new Date().toISOString().replace(/[:.]/gu, "-");
const outputDir = path.join(redaction.stateDir, "logs", "support");
const promptPath = path.join(outputDir, `openclaw-triage-prompt-${now}-${process.pid}.md`);
await fs.mkdir(outputDir, { recursive: true, mode: 0o700 });
await fs.writeFile(promptPath, prompt, { encoding: "utf8", mode: 0o600 });
// Operator-facing paths and shell commands stay real; only agent prompt content is path-redacted.
const quotedPath = quoteShellArgument(promptPath);
const suggestedCommands = [
`claude "$(cat ${quotedPath})"`,
`codex exec - < ${quotedPath}`,
"openclaw triage --run",
];
const findingCounts: Record<HealthFindingSeverity, number> = {
error: 0,
warning: 0,
info: 0,
};
for (const finding of findings) {
findingCounts[finding.severity] += 1;
}
let handoff = resolveTriageHandoff(options);
const externalAgents =
options.json === true || handoff.kind === "offer"
? (["claude", "codex"] as const).flatMap((agent) => {
const executablePath = resolveExecutablePath(agent);
return executablePath ? [{ agent, executablePath }] : [];
})
: [];
const detectedAgents = externalAgents.map(({ agent }) => agent);
const report = {
promptPath,
bundlePath: bundle.kind === "available" ? bundle.path : null,
bundleError:
bundle.kind === "unavailable" ? redactSupportString(bundle.reason, redaction) : null,
findings: findingCounts,
detectedAgents,
suggestedCommands,
};
if (options.json === true) {
writeRuntimeJson(runtime, report);
return;
}
runtime.log(`Debugging prompt: ${promptPath}`);
if (bundle.kind === "available") {
runtime.log(`Sanitized diagnostics: ${bundle.path}`);
} else if (bundle.kind === "unavailable") {
runtime.log(`Diagnostics export unavailable: ${report.bundleError}`);
}
if (handoff.kind === "offer") {
const snapshot = await readConfigFileSnapshot({ observe: false });
const config = snapshot.runtimeConfig ?? snapshot.config;
const agentId = tryResolveAmbientOwnerAgentId(config);
const choices: Parameters<typeof select<TriageHandoff>>[0]["options"] = [];
if (
snapshot.exists &&
snapshot.valid &&
agentId &&
resolveAgentEffectiveModelPrimary(config, agentId)
) {
choices.push({ value: { kind: "embedded" }, label: "OpenClaw embedded agent" });
}
for (const { agent, executablePath } of externalAgents) {
// Windows command shims need a shell, so keep them manual-only rather than offering a broken launch.
if (process.platform === "win32" && /\.(?:cmd|bat)$/iu.test(executablePath)) {
continue;
}
choices.push({
value: { kind: "external", agent, executablePath },
label: agent === "claude" ? "Claude Code" : "Codex CLI",
});
}
choices.push({ value: { kind: "print" }, label: "Just print the commands" });
const selected = await select<TriageHandoff>({
message: "Choose an agent to investigate this OpenClaw installation",
options: choices,
});
if (typeof selected === "symbol") {
runtime.exit(130);
return;
}
handoff = selected;
}
if (handoff.kind === "print" || handoff.kind === "embedded") {
runtime.log("Ready-to-run agent handoffs:");
for (const command of suggestedCommands) {
runtime.log(` ${command}`);
}
if (handoff.kind === "print") {
return;
}
}
if (handoff.kind === "external") {
let exitCode: number;
try {
exitCode = await new Promise<number>((resolve, reject) => {
const child = spawn(handoff.executablePath, [prompt], { stdio: "inherit" });
child.once("error", reject);
child.once("exit", (code, signal) => resolve(resolveSubprocessExitCode(code, signal)));
});
} catch (error) {
runtime.error(`Failed to launch ${handoff.agent}: ${scrubDoctorErrorMessage(error)}`);
runtime.log(`Run manually: ${suggestedCommands[handoff.agent === "claude" ? 0 : 1]}`);
runtime.exit(1);
return;
}
if (exitCode !== 0) {
runtime.exit(exitCode);
}
return;
}
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error(
"Embedded triage requires an interactive terminal; use a suggested handoff command.",
);
}
const { verifySetupInference } = await import("../system-agent/setup-inference.js");
const inference = await verifySetupInference({ runtime, timeoutMs: 15_000 });
if (!inference.ok) {
const reason = redactSupportString(scrubDoctorErrorMessage(inference.error), redaction);
const message = `Embedded agent unavailable: ${reason}. Run \`openclaw onboard\` or use a suggested handoff command.`;
if (options.run === true) {
throw new Error(message);
}
runtime.log(message);
return;
}
const { agentExecCommand } = await import("./agent-exec.js");
const result = await agentExecCommand(undefined, { messageFile: promptPath }, runtime);
if (result.exitCode !== 0) {
runtime.exit(result.exitCode);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { scrubDoctorErrorMessage } from "./doctor-error-message.js";
describe("scrubDoctorErrorMessage", () => {
it("keeps word separation for multi-line errors", () => {
const scrubbed = scrubDoctorErrorMessage(
new Error("Gateway not reachable.\nStart it with `openclaw gateway run`.\r\n\tCheck status."),
);
expect(scrubbed).toBe(
"Gateway not reachable. Start it with `openclaw gateway run`. Check status.",
);
});
it("drops non-whitespace control characters and caps length", () => {
const scrubbed = scrubDoctorErrorMessage(`a\u0000b\u0007c ${"x".repeat(300)}`);
expect(scrubbed.startsWith("abc x")).toBe(true);
expect(scrubbed.endsWith("...")).toBe(true);
expect(scrubbed.length).toBeLessThanOrEqual(256);
});
});
+5 -1
View File
@@ -9,10 +9,14 @@ export function scrubDoctorErrorMessage(err: unknown): string {
let stripped = "";
for (let index = 0; index < raw.length; index++) {
const code = raw.charCodeAt(index);
if (code > 0x1f && code !== 0x7f) {
if (code === 0x09 || code === 0x0a || code === 0x0d) {
// Whitespace controls become spaces so multi-line errors don't glue words together.
stripped += " ";
} else if (code > 0x1f && code !== 0x7f) {
stripped += raw.charAt(index);
}
}
stripped = stripped.replace(/ {2,}/gu, " ").trim();
if (stripped.length <= ERR_MESSAGE_MAX_LEN) {
return stripped;
}