From 65ec5a0acfe478f06b5fef38e532f648283d98cd Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Sat, 4 Jul 2026 17:51:41 -0700 Subject: [PATCH] test(doctor): make systemd linger lint test portable --- docs/reference/wizard.md | 19 +++----- docs/tools/acp-agents.md | 48 +++++++++---------- .../src/browser/routes/agent.shared.ts | 2 +- src/agents/subagent-registry-helpers.test.ts | 16 ++++++- src/agents/subagent-registry-helpers.ts | 13 ++--- src/gateway/server/close-reason.ts | 15 +----- src/process/exec.test.ts | 14 ++++++ src/process/exec.ts | 7 +-- src/utils/utf8-truncate.test.ts | 35 ++++++++++++++ src/utils/utf8-truncate.ts | 37 ++++++++++++++ 10 files changed, 144 insertions(+), 62 deletions(-) create mode 100644 src/utils/utf8-truncate.test.ts create mode 100644 src/utils/utf8-truncate.ts diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 6a2fa4a89cbf..2f46b0127f22 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -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 ` 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//`. -- 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//`. +- 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. diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 40da8c5f5f12..ff31e398b230 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -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 "" 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 .` | 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 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 .` | 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 `<<>>` | 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 "" 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 .` | 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 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 .` | 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 `<<>>` | Internal event envelope leaked across the ACP boundary. | Update OpenClaw and rerun the completion flow; external harnesses should receive plain completion prompts only. | `Command blocked by PreToolUse hook: Native hook relay unavailable` belongs to diff --git a/extensions/browser/src/browser/routes/agent.shared.ts b/extensions/browser/src/browser/routes/agent.shared.ts index 3f922c576bfa..af109a618e43 100644 --- a/extensions/browser/src/browser/routes/agent.shared.ts +++ b/extensions/browser/src/browser/routes/agent.shared.ts @@ -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; diff --git a/src/agents/subagent-registry-helpers.test.ts b/src/agents/subagent-registry-helpers.test.ts index 003846dc1892..181825da8eb6 100644 --- a/src/agents/subagent-registry-helpers.test.ts +++ b/src/agents/subagent-registry-helpers.test.ts @@ -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 { @@ -20,6 +24,16 @@ function createRunEntry(overrides: Partial = {}): 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(); diff --git a/src/agents/subagent-registry-helpers.ts b/src/agents/subagent-registry-helpers.ts index 0c6ee7701719..2f5315cbc51f 100644 --- a/src/agents/subagent-registry-helpers.ts +++ b/src/agents/subagent-registry-helpers.ts @@ -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}`; } diff --git a/src/gateway/server/close-reason.ts b/src/gateway/server/close-reason.ts index 6c5c6dd7e35a..fe0ba15c0631 100644 --- a/src/gateway/server/close-reason.ts +++ b/src/gateway/server/close-reason.ts @@ -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 (0x00–0x7F or 0xC0–0xFF). - let end = maxBytes; - while (end > 0 && (buf[end] & 0xc0) === 0x80) { - end--; - } - return buf.subarray(0, end).toString(); + return truncateUtf8Prefix(reason, maxBytes); } diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index bb86b7936f1a..0b1fa0d357fe 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -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", () => { diff --git a/src/process/exec.ts b/src/process/exec.ts index c1b0f6c2c748..32e727b87958 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -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: { diff --git a/src/utils/utf8-truncate.test.ts b/src/utils/utf8-truncate.test.ts new file mode 100644 index 000000000000..14c957f138da --- /dev/null +++ b/src/utils/utf8-truncate.test.ts @@ -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(""); + }); +}); diff --git a/src/utils/utf8-truncate.ts b/src/utils/utf8-truncate.ts new file mode 100644 index 000000000000..474d3fa83307 --- /dev/null +++ b/src/utils/utf8-truncate.ts @@ -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"); +}