test(doctor): make systemd linger lint test portable

This commit is contained in:
Gio Della-Libera
2026-07-04 17:51:41 -07:00
committed by GitHub
parent e3d0f4191a
commit 65ec5a0acf
10 changed files with 144 additions and 62 deletions
+7 -12
View File
@@ -119,7 +119,7 @@ For a high-level overview, see [Onboarding (CLI)](/start/wizard).
- Linux (and Windows via WSL2): systemd user unit
- Onboarding attempts to enable lingering via `loginctl enable-linger <user>` so the Gateway stays up after logout.
- May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first.
- **Runtime selection:** Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**.
- **Runtime selection:** interactive setup offers **Node** only. WhatsApp and Telegram require Node; Bun can corrupt memory on reconnect, and `openclaw doctor` flags Bun-based Gateway services as incompatible with those channels.
- If token auth requires a token and `gateway.auth.token` is SecretRef-managed, daemon install validates it but does not persist resolved plaintext token values into supervisor service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, daemon install is blocked with actionable guidance.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, daemon install is blocked until mode is set explicitly.
@@ -203,17 +203,12 @@ Clients (macOS app, Control UI) can render steps without re-implementing onboard
## Signal setup (signal-cli)
Onboarding can install `signal-cli` from GitHub releases:
Onboarding can install `signal-cli` for you:
- Downloads the appropriate release asset.
- Stores it under `~/.openclaw/tools/signal-cli/<version>/`.
- Writes `channels.signal.cliPath` to your config.
Notes:
- JVM builds require **Java 21**.
- Native builds are used when available.
- Windows uses WSL2; signal-cli install follows the Linux flow inside WSL.
- Linux x64: downloads the official native build from GitHub releases and stores it under `~/.openclaw/tools/signal-cli/<version>/`.
- macOS and other platforms without a native release build: installs via Homebrew (`brew install signal-cli`).
- Windows: auto-install is not supported yet; install `signal-cli` manually and point `channels.signal.cliPath` at it. Inside WSL2 the Linux flow applies.
- The resolved binary path is written to `channels.signal.cliPath` in your config.
## What the wizard writes
@@ -225,7 +220,7 @@ Typical fields in `~/.openclaw/openclaw.json`:
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (behavior details: [CLI Setup Reference](/start/wizard-cli-reference#outputs-and-internals))
- `channels.telegram.botToken`, `channels.discord.token`, `channels.matrix.*`, `channels.signal.*`, `channels.imessage.*`
- Channel allowlists (Slack/Discord/Matrix/Microsoft Teams) when you opt in during the prompts (names resolve to IDs when possible).
- Channel DM allowlists when you opt in during the channel prompts. Discord, Matrix, Microsoft Teams, and Slack resolve names to IDs when possible; other channels take IDs directly (for example numeric Telegram sender IDs or WhatsApp phone numbers).
- `skills.install.nodeManager`
- `setup --node-manager` accepts `npm`, `pnpm`, or `bun`.
- Manual config can still use `yarn` by setting `skills.install.nodeManager` directly.
+24 -24
View File
@@ -828,30 +828,30 @@ permission modes, see
## Troubleshooting
| Symptom | Likely cause | Fix |
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ACP runtime backend is not configured` | Backend plugin missing, disabled, or blocked by `plugins.allow`. | Install and enable backend plugin, include `acpx` in `plugins.allow` when that allowlist is set, then run `/acp doctor`. |
| `ACP is disabled by policy (acp.enabled=false)` | ACP globally disabled. | Set `acp.enabled=true`. |
| `ACP dispatch is disabled by policy (acp.dispatch.enabled=false)` | Automatic dispatch from normal thread messages disabled. | Set `acp.dispatch.enabled=true` to resume automatic thread routing; explicit `sessions_spawn({ runtime: "acp" })` calls still work. |
| `ACP agent "<id>" is not allowed by policy` | Agent not in allowlist. | Use allowed `agentId` or update `acp.allowedAgents`. |
| `/acp doctor` reports backend not ready right after startup | Backend plugin is missing, disabled, blocked by allow/deny policy, or its configured executable is unavailable. | Install/enable the backend plugin, rerun `/acp doctor`, and inspect the backend install or policy error if it stays unhealthy. |
| Harness command not found | Adapter CLI is not installed, the external plugin is missing, or first-run `npx` fetch failed for a non-Codex adapter. | Run `/acp doctor`, install/prewarm the adapter on the Gateway host, or configure the acpx agent command explicitly. |
| Model-not-found from the harness | Model id is valid for another provider/harness but not this ACP target. | Use a model listed by that harness, configure the model in the harness, or omit the override. |
| Vendor auth error from the harness | OpenClaw is healthy, but the target CLI/provider is not logged in. | Log in or provide the required provider key on the Gateway host environment. |
| `Unable to resolve session target: ...` | Bad key/id/label token. | Run `/acp sessions`, copy exact key/label, retry. |
| `--bind here requires running /acp spawn inside an active ... conversation` | `--bind here` used without an active bindable conversation. | Move to the target chat/channel and retry, or use unbound spawn. |
| `Conversation bindings are unavailable for <channel>.` | Adapter lacks current-conversation ACP binding capability. | Use `/acp spawn ... --thread ...` where supported, configure top-level `bindings[]`, or move to a supported channel. |
| `--thread here requires running /acp spawn inside an active ... thread` | `--thread here` used outside a thread context. | Move to target thread or use `--thread auto`/`off`. |
| `Only <user-id> can rebind this channel/conversation/thread.` | Another user owns the active binding target. | Rebind as owner or use a different conversation or thread. |
| `Thread bindings are unavailable for <channel>.` | Adapter lacks thread binding capability. | Use `--thread off` or move to supported adapter/channel. |
| `Sandboxed sessions cannot spawn ACP sessions ...` | ACP runtime is host-side; requester session is sandboxed. | Use `runtime="subagent"` from sandboxed sessions, or run ACP spawn from a non-sandboxed session. |
| `sessions_spawn sandbox="require" is unsupported for runtime="acp" ...` | `sandbox="require"` requested for ACP runtime. | Use `runtime="subagent"` for required sandboxing, or use ACP with `sandbox="inherit"` from a non-sandboxed session. |
| `Cannot apply --model ... did not advertise model support` | The target harness does not expose generic ACP model switching. | Use a harness that advertises ACP `models`/`session/set_model`, use Codex ACP model refs, or configure the model directly in the harness if it has its own startup flag. |
| Missing ACP metadata for bound session | Stale/deleted ACP session metadata. | Recreate with `/acp spawn`, then rebind/focus thread. |
| `AcpRuntimeError: Permission prompt unavailable in non-interactive mode` | `permissionMode` blocks writes/exec in non-interactive ACP session. | Set `plugins.entries.acpx.config.permissionMode` to `approve-all` and restart gateway. See [Permission configuration](/tools/acp-agents-setup#permission-configuration). |
| ACP session fails early with little output | Permission prompts are blocked by `permissionMode`/`nonInteractivePermissions`. | Check gateway logs for `AcpRuntimeError`. For full permissions, set `permissionMode=approve-all`; for graceful degradation, set `nonInteractivePermissions=deny`. |
| ACP session stalls indefinitely after completing work | Harness process finished but ACP session did not report completion. | Update OpenClaw; current acpx cleanup reaps OpenClaw-owned stale wrapper and adapter processes on close and Gateway startup. |
| Harness sees `<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>` | Internal event envelope leaked across the ACP boundary. | Update OpenClaw and rerun the completion flow; external harnesses should receive plain completion prompts only. |
| Symptom | Likely cause | Fix |
| ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ACP runtime backend is not configured` | Backend plugin missing, disabled, or blocked by `plugins.allow`. | Install and enable backend plugin, include `acpx` in `plugins.allow` when that allowlist is set, then run `/acp doctor`. |
| `ACP is disabled by policy (acp.enabled=false)` | ACP globally disabled. | Set `acp.enabled=true`. |
| `ACP dispatch is disabled by policy (acp.dispatch.enabled=false)` | Automatic dispatch from normal thread messages disabled. | Set `acp.dispatch.enabled=true` to resume automatic thread routing; explicit `sessions_spawn({ runtime: "acp" })` calls still work. |
| `ACP agent "<id>" is not allowed by policy` | Agent not in allowlist. | Use allowed `agentId` or update `acp.allowedAgents`. |
| `/acp doctor` reports backend not ready right after startup | Backend plugin is missing, disabled, blocked by allow/deny policy, or its configured executable is unavailable. | Install/enable the backend plugin, rerun `/acp doctor`, and inspect the backend install or policy error if it stays unhealthy. |
| Harness command not found | Adapter CLI is not installed, the external plugin is missing, or first-run `npx` fetch failed for a non-Codex adapter. | Run `/acp doctor`, install/prewarm the adapter on the Gateway host, or configure the acpx agent command explicitly. |
| Model-not-found from the harness | Model id is valid for another provider/harness but not this ACP target. | Use a model listed by that harness, configure the model in the harness, or omit the override. |
| Vendor auth error from the harness | OpenClaw is healthy, but the target CLI/provider is not logged in. | Log in or provide the required provider key on the Gateway host environment. |
| `Unable to resolve session target: ...` | Bad key/id/label token. | Run `/acp sessions`, copy exact key/label, retry. |
| `--bind here requires running /acp spawn inside an active ... conversation` | `--bind here` used without an active bindable conversation. | Move to the target chat/channel and retry, or use unbound spawn. |
| `Conversation bindings are unavailable for <channel>.` | Adapter lacks current-conversation ACP binding capability. | Use `/acp spawn ... --thread ...` where supported, configure top-level `bindings[]`, or move to a supported channel. |
| `--thread here requires running /acp spawn inside an active ... thread` | `--thread here` used outside a thread context. | Move to target thread or use `--thread auto`/`off`. |
| `Only <user-id> can rebind this channel/conversation/thread.` | Another user owns the active binding target. | Rebind as owner or use a different conversation or thread. |
| `Thread bindings are unavailable for <channel>.` | Adapter lacks thread binding capability. | Use `--thread off` or move to supported adapter/channel. |
| `Sandboxed sessions cannot spawn ACP sessions ...` | ACP runtime is host-side; requester session is sandboxed. | Use `runtime="subagent"` from sandboxed sessions, or run ACP spawn from a non-sandboxed session. |
| `sessions_spawn sandbox="require" is unsupported for runtime="acp" ...` | `sandbox="require"` requested for ACP runtime. | Use `runtime="subagent"` for required sandboxing, or use ACP with `sandbox="inherit"` from a non-sandboxed session. |
| `Cannot apply --model ... did not advertise model support` | The target harness does not expose generic ACP model switching. | Use a harness that advertises ACP `models`/`session/set_model`, use Codex ACP model refs, or configure the model directly in the harness if it has its own startup flag. |
| Missing ACP metadata for bound session | Stale/deleted ACP session metadata. | Recreate with `/acp spawn`, then rebind/focus thread. |
| `PermissionPromptUnavailableError: Permission prompt unavailable in non-interactive mode` | `permissionMode` blocks writes/exec in non-interactive ACP session. | Set `plugins.entries.acpx.config.permissionMode` to `approve-all` and restart gateway. See [Permission configuration](/tools/acp-agents-setup#permission-configuration). |
| ACP session fails early with little output | Permission prompts are blocked by `permissionMode`/`nonInteractivePermissions`. | Check gateway logs for `AcpRuntimeError`. For full permissions, set `permissionMode=approve-all`; for graceful degradation, set `nonInteractivePermissions=deny`. |
| ACP session stalls indefinitely after completing work | Harness process finished but ACP session did not report completion. | Update OpenClaw; current acpx cleanup reaps OpenClaw-owned stale wrapper and adapter processes on close and Gateway startup. |
| Harness sees `<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>` | Internal event envelope leaked across the ACP boundary. | Update OpenClaw and rerun the completion flow; external harnesses should receive plain completion prompts only. |
<Note>
`Command blocked by PreToolUse hook: Native hook relay unavailable` belongs to
@@ -108,7 +108,7 @@ export async function requirePwAi(
[
`Playwright is not available in this gateway build; '${feature}' is unsupported.`,
"Reinstall or update OpenClaw so the core browser runtime dependency is present, then restart the gateway. In Docker, also install Chromium with the bundled playwright-core CLI.",
"Docs: /tools/browser#playwright-requirement",
"Docs: /tools/browser-control#playwright-requirement",
].join("\n"),
);
return null;
+15 -1
View File
@@ -2,7 +2,11 @@
// for announce delivery give-up paths.
import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultRuntime } from "../runtime.js";
import { logAnnounceGiveUp, reconcileOrphanedRun } from "./subagent-registry-helpers.js";
import {
capFrozenResultText,
logAnnounceGiveUp,
reconcileOrphanedRun,
} from "./subagent-registry-helpers.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
function createRunEntry(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
@@ -20,6 +24,16 @@ function createRunEntry(overrides: Partial<SubagentRunRecord> = {}): SubagentRun
};
}
describe("capFrozenResultText", () => {
it("preserves a valid UTF-8 prefix within the frozen-result byte budget", () => {
const result = capFrozenResultText("😀".repeat(25_601));
expect(Buffer.byteLength(result, "utf8")).toBeLessThanOrEqual(100 * 1024);
expect(result).not.toContain("");
expect(result).toContain("[truncated: frozen completion output exceeded 100KB");
});
});
describe("reconcileOrphanedRun", () => {
afterEach(() => {
vi.useRealTimers();
+7 -6
View File
@@ -7,24 +7,25 @@ import fsSync, { promises as fs } from "node:fs";
import path from "node:path";
import { DEFAULT_SUBAGENT_ARCHIVE_AFTER_MINUTES } from "../config/agent-limits.js";
import { getRuntimeConfig } from "../config/config.js";
import { patchSessionEntry } from "../config/sessions/session-accessor.js";
import { resolveAgentIdFromSessionKey, resolveStorePath } from "../config/sessions.js";
import { patchSessionEntry } from "../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { defaultRuntime } from "../runtime.js";
import { truncateUtf8Prefix } from "../utils/utf8-truncate.js";
import { withSubagentOutcomeTiming } from "./subagent-announce-output.js";
import { getDeliveryAttemptCount, getDeliveryLastError } from "./subagent-delivery-state.js";
import { SUBAGENT_ENDED_REASON_ERROR } from "./subagent-lifecycle-events.js";
import { shouldUpdateRunOutcome } from "./subagent-registry-completion.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
import {
resolveSubagentRunOrphanReason,
type SubagentRunOrphanReason,
} from "./subagent-session-reconciliation.js";
import {
getSubagentSessionRuntimeMs,
getSubagentSessionStartedAt,
resolveSubagentSessionStatus,
} from "./subagent-session-metrics.js";
import {
resolveSubagentRunOrphanReason,
type SubagentRunOrphanReason,
} from "./subagent-session-reconciliation.js";
export {
getSubagentSessionRuntimeMs,
@@ -55,7 +56,7 @@ export function capFrozenResultText(resultText: string): string {
0,
FROZEN_RESULT_TEXT_MAX_BYTES - Buffer.byteLength(notice, "utf8"),
);
const payload = Buffer.from(trimmed, "utf8").subarray(0, maxPayloadBytes).toString("utf8");
const payload = truncateUtf8Prefix(trimmed, maxPayloadBytes);
return `${payload}${notice}`;
}
+2 -13
View File
@@ -1,5 +1,5 @@
// Close reason helpers keep WebSocket handshake failure text within RFC byte limits.
import { Buffer } from "node:buffer";
import { truncateUtf8Prefix } from "../../utils/utf8-truncate.js";
/**
* WebSocket close reason utilities.
@@ -11,16 +11,5 @@ export function truncateCloseReason(reason: string, maxBytes = CLOSE_REASON_MAX_
if (!reason) {
return "invalid handshake";
}
const buf = Buffer.from(reason);
if (buf.length <= maxBytes) {
return reason;
}
// Back up from the byte cap to avoid cutting inside a multi-byte UTF-8 sequence.
// UTF-8 continuation bytes have the form 10xxxxxx; the start byte of a sequence
// is any byte that is NOT a continuation byte (0x000x7F or 0xC00xFF).
let end = maxBytes;
while (end > 0 && (buf[end] & 0xc0) === 0x80) {
end--;
}
return buf.subarray(0, end).toString();
return truncateUtf8Prefix(reason, maxBytes);
}
+14
View File
@@ -386,6 +386,20 @@ describe("runCommandWithTimeout", () => {
expect(result.stdoutTruncatedBytes).toBeGreaterThan(0);
expect(result.preservedStdoutLines).toEqual(["x".repeat(24)]);
});
it("keeps preserved line tails on a UTF-8 boundary", async () => {
await loadExecModules();
const result = await runCommandWithTimeout(
[process.execPath, "-e", "process.stdout.write('😀' + 'x'.repeat(22))"],
{
timeoutMs: 3_000,
maxOutputBytes: 24,
preserveOutputLine: () => true,
},
);
expect(result.preservedStdoutLines).toEqual(["x".repeat(22)]);
});
});
describe("attachChildProcessBridge", () => {
+2 -5
View File
@@ -15,6 +15,7 @@ import {
import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js";
import { logDebug, logError } from "../logger.js";
import { resolveTimerTimeoutMs } from "../shared/number-coercion.js";
import { truncateUtf8Suffix } from "../utils/utf8-truncate.js";
import { killProcessTree as terminateProcessTree } from "./kill-tree.js";
import { resolveCommandStdio } from "./spawn-utils.js";
import {
@@ -292,11 +293,7 @@ function appendCapturedOutput(
}
function trimPreservedPendingLine(value: string, maxBytes: number): string {
if (Buffer.byteLength(value) <= maxBytes) {
return value;
}
const buffer = Buffer.from(value);
return buffer.subarray(buffer.byteLength - maxBytes).toString();
return truncateUtf8Suffix(value, maxBytes);
}
function appendPreservedOutputLines(params: {
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { truncateUtf8Prefix, truncateUtf8Suffix } from "./utf8-truncate.js";
describe("UTF-8 byte truncation", () => {
it.each([
{ value: "abcé", maxBytes: 4, expected: "abc" },
{ value: "abc✓", maxBytes: 5, expected: "abc" },
{ value: "abc😀", maxBytes: 6, expected: "abc" },
{ value: "😀", maxBytes: 4, expected: "😀" },
])("keeps a valid prefix for $value at $maxBytes bytes", ({ value, maxBytes, expected }) => {
const result = truncateUtf8Prefix(value, maxBytes);
expect(result).toBe(expected);
expect(Buffer.byteLength(result)).toBeLessThanOrEqual(maxBytes);
expect(result).not.toContain("");
});
it.each([
{ value: "éabc", maxBytes: 4, expected: "abc" },
{ value: "✓abc", maxBytes: 5, expected: "abc" },
{ value: "😀abc", maxBytes: 6, expected: "abc" },
{ value: "😀", maxBytes: 4, expected: "😀" },
])("keeps a valid suffix for $value at $maxBytes bytes", ({ value, maxBytes, expected }) => {
const result = truncateUtf8Suffix(value, maxBytes);
expect(result).toBe(expected);
expect(Buffer.byteLength(result)).toBeLessThanOrEqual(maxBytes);
expect(result).not.toContain("");
});
it("returns an empty string for a non-positive limit", () => {
expect(truncateUtf8Prefix("value", 0)).toBe("");
expect(truncateUtf8Suffix("value", -1)).toBe("");
});
});
+37
View File
@@ -0,0 +1,37 @@
import { Buffer } from "node:buffer";
function isContinuationByte(byte: number | undefined): boolean {
return byte !== undefined && (byte & 0xc0) === 0x80;
}
/** Keeps the longest UTF-8 prefix that fits within the byte limit. */
export function truncateUtf8Prefix(value: string, maxBytes: number): string {
if (maxBytes <= 0) {
return "";
}
const bytes = Buffer.from(value);
if (bytes.byteLength <= maxBytes) {
return value;
}
let end = maxBytes;
while (end > 0 && isContinuationByte(bytes[end])) {
end -= 1;
}
return bytes.subarray(0, end).toString("utf8");
}
/** Keeps the longest UTF-8 suffix that fits within the byte limit. */
export function truncateUtf8Suffix(value: string, maxBytes: number): string {
if (maxBytes <= 0) {
return "";
}
const bytes = Buffer.from(value);
if (bytes.byteLength <= maxBytes) {
return value;
}
let start = bytes.byteLength - maxBytes;
while (start < bytes.byteLength && isContinuationByte(bytes[start])) {
start += 1;
}
return bytes.subarray(start).toString("utf8");
}