mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 05:13:57 +00:00
fix: harden small runtime and installer edge cases (#100258)
* fix(media): preserve dollar sequences in transcript echoes Co-authored-by: uditDewan <udit.dewan21@gmail.com> * fix(mcp): catch rejected gateway event handlers Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com> * fix(auto-reply): support punctuated silent token prefixes Co-authored-by: simon-w <weng.qimeng@xydigit.com> * fix(discord): keep voice log previews UTF-16 safe Co-authored-by: sunlit-deng <yang.jiajun1@xydigit.com> * fix(clawrouter): bound usage response reads Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com> * fix(browser): bound CDP JSON response reads Co-authored-by: hailory <hailory@xydigit.com> * fix(status): include current time in session status Co-authored-by: connermo <conner.mo@gmail.com> * fix(installer): require Arch detection before pacman Co-authored-by: Iliya Abolghasemi <gfaerny@gmail.com> * fix(apps): default TLS gateway deep links to port 443 Co-authored-by: ben.li <li.yang6@xydigit.com> * fix(infra): keep Undici terminated exceptions nonfatal Co-authored-by: harjoth <harjoth.khara@gmail.com> * fix(auto-reply): avoid Unicode-unsafe token spread --------- Co-authored-by: uditDewan <udit.dewan21@gmail.com> Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com> Co-authored-by: simon-w <weng.qimeng@xydigit.com> Co-authored-by: sunlit-deng <yang.jiajun1@xydigit.com> Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com> Co-authored-by: hailory <hailory@xydigit.com> Co-authored-by: connermo <conner.mo@gmail.com> Co-authored-by: Iliya Abolghasemi <gfaerny@gmail.com> Co-authored-by: ben.li <li.yang6@xydigit.com> Co-authored-by: harjoth <harjoth.khara@gmail.com>
This commit is contained in:
committed by
GitHub
parent
43fd44a28c
commit
deac98eb72
@@ -253,8 +253,8 @@ public enum DeepLinkParser {
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let port = query["port"].flatMap { Int($0) } ?? 18789
|
||||
let tls = (query["tls"] as NSString?)?.boolValue ?? false
|
||||
let port = query["port"].flatMap { Int($0) } ?? (tls ? 443 : 18789)
|
||||
if !tls, !LoopbackHost.isLocalNetworkHost(hostParam) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -21,6 +21,19 @@ private func setupCode(from payload: String) -> String {
|
||||
#expect(DeepLinkParser.parse(url) == .dashboard)
|
||||
}
|
||||
|
||||
@Test func gatewayDeepLinkUsesTlsDefaultPortWhenPortMissing() {
|
||||
let url = URL(string: "openclaw://gateway?host=gateway.example.com&tls=1&token=abc")!
|
||||
#expect(
|
||||
DeepLinkParser.parse(url) == .gateway(
|
||||
.init(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: true,
|
||||
bootstrapToken: nil,
|
||||
token: "abc",
|
||||
password: nil)))
|
||||
}
|
||||
|
||||
@Test func gatewayDeepLinkRejectsInsecureNonLoopbackWs() {
|
||||
let url = URL(
|
||||
string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")!
|
||||
|
||||
@@ -38,15 +38,16 @@ describe("cdp helpers", () => {
|
||||
|
||||
it("releases guarded CDP fetches after the response body is consumed", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const json = vi.fn(async () => {
|
||||
const arrayBuffer = vi.fn(async () => {
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
return { ok: true };
|
||||
return new TextEncoder().encode(JSON.stringify({ ok: true })).buffer;
|
||||
});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json,
|
||||
body: null,
|
||||
arrayBuffer,
|
||||
},
|
||||
release,
|
||||
});
|
||||
@@ -58,7 +59,20 @@ describe("cdp helpers", () => {
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(json).toHaveBeenCalledTimes(1);
|
||||
expect(arrayBuffer).toHaveBeenCalledTimes(1);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects oversized CDP JSON responses before parsing", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: new Response(new Uint8Array(16 * 1024 * 1024 + 1)),
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(fetchJson("http://127.0.0.1:9222/json/version")).rejects.toThrow(
|
||||
"cdp-json: JSON response exceeds 16777216 bytes",
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* redaction/headers, and request/response correlation over WebSocket.
|
||||
*/
|
||||
import { parseBrowserHttpUrl, redactCdpUrl } from "openclaw/plugin-sdk/browser-config";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import WebSocket from "ws";
|
||||
@@ -317,7 +318,7 @@ export async function fetchJson<T>(
|
||||
): Promise<T> {
|
||||
const { response, release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy);
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
return await readProviderJsonResponse<T>(response, "cdp-json");
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
/**
|
||||
* Chrome CDP diagnostics.
|
||||
*
|
||||
@@ -110,7 +111,7 @@ export async function readChromeVersion(
|
||||
ssrfPolicy,
|
||||
);
|
||||
try {
|
||||
const data = (await response.json()) as ChromeVersion;
|
||||
const data = await readProviderJsonResponse<ChromeVersion>(response, "cdp-version");
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("CDP /json/version returned non-object JSON");
|
||||
}
|
||||
@@ -250,7 +251,11 @@ function classifyChromeVersionError(error: unknown): {
|
||||
if (/^HTTP \d+/.test(message)) {
|
||||
return { code: "http_status_failed", message };
|
||||
}
|
||||
if (error instanceof SyntaxError || message.includes("non-object JSON")) {
|
||||
if (
|
||||
error instanceof SyntaxError ||
|
||||
message.includes("cdp-version: malformed JSON response") ||
|
||||
message.includes("non-object JSON")
|
||||
) {
|
||||
return { code: "invalid_json", message };
|
||||
}
|
||||
return { code: "http_unreachable", message };
|
||||
|
||||
@@ -59,6 +59,13 @@ function expectReadyChromeCdpDiagnostic(
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
async function readJson(filePath: string): Promise<Record<string, unknown>> {
|
||||
const raw = await fsp.readFile(filePath, "utf-8");
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
@@ -467,20 +474,11 @@ describe("browser chrome helpers", () => {
|
||||
it("reports reachability based on /json/version", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
|
||||
} as unknown as Response),
|
||||
vi.fn().mockResolvedValue(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" })),
|
||||
);
|
||||
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(true);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response),
|
||||
);
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({}, 500)));
|
||||
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom")));
|
||||
@@ -488,13 +486,7 @@ describe("browser chrome helpers", () => {
|
||||
});
|
||||
|
||||
it("diagnoses /json/version responses that omit the websocket URL", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Browser: "Chrome/Mock" }),
|
||||
} as unknown as Response),
|
||||
);
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ Browser: "Chrome/Mock" })));
|
||||
|
||||
const diagnostic = expectFailedChromeCdpDiagnostic(
|
||||
await diagnoseChromeCdp("http://127.0.0.1:12345", 50, 50),
|
||||
@@ -503,13 +495,26 @@ describe("browser chrome helpers", () => {
|
||||
expect(diagnostic.cdpUrl).toBe("http://127.0.0.1:12345");
|
||||
});
|
||||
|
||||
it("preserves invalid-json diagnostics for bounded /json/version reads", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response("{", {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const diagnostic = expectFailedChromeCdpDiagnostic(
|
||||
await diagnoseChromeCdp("http://127.0.0.1:12345", 50, 50),
|
||||
);
|
||||
expect(diagnostic.code).toBe("invalid_json");
|
||||
});
|
||||
|
||||
it("allows loopback CDP probes while still blocking non-loopback private targets in strict SSRF mode", async () => {
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
|
||||
} as unknown as Response)
|
||||
.mockResolvedValueOnce(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }))
|
||||
.mockRejectedValue(new Error("should not be called"));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
@@ -794,10 +799,7 @@ describe("browser chrome helpers", () => {
|
||||
// connections (Browserless/Browserbase-style provider).
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}), // empty — no webSocketDebuggerUrl
|
||||
} as unknown as Response),
|
||||
vi.fn().mockResolvedValue(jsonResponse({})), // empty — no webSocketDebuggerUrl
|
||||
);
|
||||
// A real WS server accepts the handshake.
|
||||
const wss = new WebSocketServer({
|
||||
@@ -819,13 +821,7 @@ describe("browser chrome helpers", () => {
|
||||
});
|
||||
|
||||
it("falls back to a direct WS readiness check when /json/version has no debugger URL", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response),
|
||||
);
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({})));
|
||||
const wss = new WebSocketServer({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
@@ -859,13 +855,7 @@ describe("browser chrome helpers", () => {
|
||||
it("returns the original ws:// URL from getChromeWebSocketUrl when /json/version provides no debugger URL", async () => {
|
||||
// Covers the getChromeWebSocketUrl WS-fallback: discovery succeeds but
|
||||
// webSocketDebuggerUrl is absent — the original URL is returned as-is.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response),
|
||||
);
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({})));
|
||||
await expect(getChromeWebSocketUrl("ws://127.0.0.1:12345", 50)).resolves.toBe(
|
||||
"ws://127.0.0.1:12345",
|
||||
);
|
||||
@@ -887,10 +877,7 @@ describe("browser chrome helpers", () => {
|
||||
it("stopOpenClawChrome escalates to SIGKILL when CDP stays reachable", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
|
||||
} as unknown as Response),
|
||||
vi.fn().mockResolvedValue(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" })),
|
||||
);
|
||||
const proc = makeChromeTestProc();
|
||||
await stopChromeWithProc(proc, 1);
|
||||
@@ -915,10 +902,7 @@ describe("browser chrome helpers", () => {
|
||||
it("stopOpenClawChrome still releases the bypass when the SIGKILL fallback fires", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
|
||||
} as unknown as Response),
|
||||
vi.fn().mockResolvedValue(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" })),
|
||||
);
|
||||
const proc = makeChromeTestProc();
|
||||
const release = vi.fn();
|
||||
|
||||
@@ -82,4 +82,23 @@ describe("ClawRouter usage", () => {
|
||||
}),
|
||||
).rejects.toThrow("ClawRouter usage request failed (HTTP 403)");
|
||||
});
|
||||
|
||||
it("bounds successful usage response bodies", async () => {
|
||||
const oversizedPayload = JSON.stringify({
|
||||
budget: { configured: false },
|
||||
usage: { summary: { requestCount: 1 } },
|
||||
padding: "x".repeat(1024 * 1024),
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchClawRouterUsage({
|
||||
token: "proxy-key",
|
||||
timeoutMs: 5000,
|
||||
fetchFn: async () =>
|
||||
new Response(oversizedPayload, {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("ClawRouter usage response exceeds");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ProviderUsageSnapshot } from "openclaw/plugin-sdk/provider-usage";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { normalizeClawRouterRootUrl } from "./provider-catalog.js";
|
||||
|
||||
const CLAWROUTER_USAGE_RESPONSE_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
type ClawRouterBudget = {
|
||||
configured?: unknown;
|
||||
ledger?: unknown;
|
||||
@@ -62,6 +65,19 @@ function buildSummary(payload: ClawRouterUsagePayload): string | undefined {
|
||||
return parts.length > 0 ? parts.join(" · ") : undefined;
|
||||
}
|
||||
|
||||
async function readClawRouterUsagePayload(
|
||||
response: Response,
|
||||
timeoutMs: number,
|
||||
): Promise<ClawRouterUsagePayload> {
|
||||
const buffer = await readResponseWithLimit(response, CLAWROUTER_USAGE_RESPONSE_MAX_BYTES, {
|
||||
chunkTimeoutMs: timeoutMs,
|
||||
onOverflow: ({ maxBytes }) => new Error(`ClawRouter usage response exceeds ${maxBytes} bytes`),
|
||||
onIdleTimeout: ({ chunkTimeoutMs }) =>
|
||||
new Error(`ClawRouter usage response stalled: no data received for ${chunkTimeoutMs}ms`),
|
||||
});
|
||||
return JSON.parse(new TextDecoder().decode(buffer)) as ClawRouterUsagePayload;
|
||||
}
|
||||
|
||||
export async function fetchClawRouterUsage(params: {
|
||||
token: string;
|
||||
baseUrl?: string;
|
||||
@@ -78,7 +94,7 @@ export async function fetchClawRouterUsage(params: {
|
||||
if (!response.ok) {
|
||||
throw new Error(`ClawRouter usage request failed (HTTP ${response.status})`);
|
||||
}
|
||||
const payload = (await response.json()) as ClawRouterUsagePayload;
|
||||
const payload = await readClawRouterUsagePayload(response, params.timeoutMs);
|
||||
const budget = payload.budget;
|
||||
const limitMicros = nonNegativeNumber(budget?.limitMicros);
|
||||
const spentMicros = nonNegativeNumber(budget?.spentMicros);
|
||||
|
||||
@@ -10,4 +10,10 @@ describe("formatVoiceLogPreview", () => {
|
||||
const preview = formatVoiceLogPreview("x".repeat(501));
|
||||
expect(preview).toBe(`${"x".repeat(500)}...`);
|
||||
});
|
||||
|
||||
it("does not split emoji when the preview limit lands inside a surrogate pair", () => {
|
||||
const preview = formatVoiceLogPreview(`${"x".repeat(499)}😀tail`);
|
||||
expect(preview).toBe(`${"x".repeat(499)}...`);
|
||||
expect(preview).not.toMatch(/[\uD800-\uDFFF]/u);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
|
||||
const DISCORD_VOICE_LOG_PREVIEW_CHARS = 500;
|
||||
|
||||
export function formatVoiceLogPreview(text: string): string {
|
||||
@@ -5,5 +7,5 @@ export function formatVoiceLogPreview(text: string): string {
|
||||
if (oneLine.length <= DISCORD_VOICE_LOG_PREVIEW_CHARS) {
|
||||
return oneLine;
|
||||
}
|
||||
return `${oneLine.slice(0, DISCORD_VOICE_LOG_PREVIEW_CHARS)}...`;
|
||||
return `${truncateUtf16Safe(oneLine, DISCORD_VOICE_LOG_PREVIEW_CHARS)}...`;
|
||||
}
|
||||
|
||||
@@ -164,6 +164,24 @@ describe("sendMatrixPreflightAudioTranscriptEcho", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps dollar sequences in the transcript literal", async () => {
|
||||
sendDurableMessageBatchMock.mockResolvedValue({ status: "sent", results: [] });
|
||||
await sendMatrixPreflightAudioTranscriptEcho({
|
||||
transcript: "tickets cost $$40, confirm with $&",
|
||||
cfg: {
|
||||
tools: { media: { audio: { echoTranscript: true, echoFormat: "heard: {transcript}" } } },
|
||||
} as import("openclaw/plugin-sdk/config-contracts").OpenClawConfig,
|
||||
accountId: "ops",
|
||||
originatingTo: "room:!room:example.org",
|
||||
});
|
||||
|
||||
expect(sendDurableMessageBatchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payloads: [{ text: "heard: tickets cost $$40, confirm with $&" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not echo when transcript echo is disabled", async () => {
|
||||
await sendMatrixPreflightAudioTranscriptEcho({
|
||||
transcript: "hello bot",
|
||||
|
||||
@@ -12,7 +12,9 @@ export function formatMatrixAudioTranscript(transcript: string): string {
|
||||
}
|
||||
|
||||
function formatMatrixAudioTranscriptEcho(transcript: string, format: string): string {
|
||||
return format.replace("{transcript}", transcript);
|
||||
// Function replacer keeps `$` sequences in the transcript literal instead of
|
||||
// being parsed as String.prototype.replace substitution patterns.
|
||||
return format.replace("{transcript}", () => transcript);
|
||||
}
|
||||
|
||||
function suppressMatrixPreflightAudioEcho(cfg: OpenClawConfig): OpenClawConfig {
|
||||
|
||||
@@ -627,10 +627,6 @@ is_arch_linux() {
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Fallback: check for pacman
|
||||
if command -v pacman &> /dev/null; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -677,7 +673,7 @@ install_build_tools_linux() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v pacman &> /dev/null || is_arch_linux; then
|
||||
if command -v pacman &> /dev/null && is_arch_linux; then
|
||||
if is_root; then
|
||||
run_quiet_step "Installing build tools" pacman -Sy --noconfirm base-devel python make cmake gcc
|
||||
else
|
||||
@@ -1814,7 +1810,7 @@ install_node() {
|
||||
fi
|
||||
|
||||
# Arch-based distros: use pacman with official repos
|
||||
if command -v pacman &> /dev/null || is_arch_linux; then
|
||||
if command -v pacman &> /dev/null && is_arch_linux; then
|
||||
ui_info "Installing Node.js via pacman (Arch-based distribution detected)"
|
||||
if is_root; then
|
||||
run_required_step "Installing Node.js" pacman -Sy --noconfirm nodejs npm
|
||||
@@ -1922,7 +1918,7 @@ install_git() {
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
run_quiet_step "Updating package index" apt_get_update
|
||||
run_quiet_step "Installing Git" apt_get_install git
|
||||
elif command -v pacman &> /dev/null || is_arch_linux; then
|
||||
elif command -v pacman &> /dev/null && is_arch_linux; then
|
||||
if is_root; then
|
||||
run_quiet_step "Installing Git" pacman -Sy --noconfirm git
|
||||
else
|
||||
|
||||
@@ -312,4 +312,30 @@ describe("isSilentReplyPrefixText", () => {
|
||||
expect(isSilentReplyPrefixText("NO_REPLY more")).toBe(false);
|
||||
expect(isSilentReplyPrefixText("NO-")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches custom tokens with digits", () => {
|
||||
expect(isSilentReplyPrefixText("NOREPLY2", "NOREPLY2")).toBe(true);
|
||||
expect(isSilentReplyPrefixText("NOREPLY", "NOREPLY2")).toBe(false);
|
||||
expect(isSilentReplyPrefixText("NORE", "NOREPLY2")).toBe(false);
|
||||
expect(isSilentReplyPrefixText("NOR", "NOREPLY2")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches custom tokens with hyphens", () => {
|
||||
expect(isSilentReplyPrefixText("NO-ANSWER", "NO-ANSWER")).toBe(true);
|
||||
expect(isSilentReplyPrefixText("NO-AN", "NO-ANSWER")).toBe(true);
|
||||
expect(isSilentReplyPrefixText("NO-", "NO-ANSWER")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-matching prefixes for custom tokens", () => {
|
||||
expect(isSilentReplyPrefixText("HE", "NOREPLY2")).toBe(false);
|
||||
expect(isSilentReplyPrefixText("HELLO", "NO-ANSWER")).toBe(false);
|
||||
expect(isSilentReplyPrefixText("NO-", "NOREPLY2")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects pure-letter prefixes for punctuated tokens to avoid false suppression", () => {
|
||||
expect(isSilentReplyPrefixText("HE", "HELP-QUIET")).toBe(false);
|
||||
expect(isSilentReplyPrefixText("HELP", "HELP-QUIET")).toBe(false);
|
||||
expect(isSilentReplyPrefixText("HELP-", "HELP-QUIET")).toBe(true);
|
||||
expect(isSilentReplyPrefixText("HELP-QUIET", "HELP-QUIET")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -310,9 +310,6 @@ export function isSilentReplyPrefixText(
|
||||
if (normalized.length < 2) {
|
||||
return false;
|
||||
}
|
||||
if (/[^A-Z_]/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
const tokenUpper = token.toUpperCase();
|
||||
if (!tokenUpper.startsWith(normalized)) {
|
||||
return false;
|
||||
@@ -320,8 +317,16 @@ export function isSilentReplyPrefixText(
|
||||
if (normalized.includes("_")) {
|
||||
return true;
|
||||
}
|
||||
// Keep underscore guard for generic tokens to avoid suppressing unrelated
|
||||
// uppercase words (e.g. HEART/HE with HEARTBEAT_OK). Only allow bare "NO"
|
||||
// because NO_REPLY streaming can transiently emit that fragment.
|
||||
// Full-token match is safe for any token.
|
||||
if (normalized === tokenUpper) {
|
||||
return true;
|
||||
}
|
||||
// For custom tokens containing non-letter characters (digits, hyphens),
|
||||
// only match if the prefix includes at least one non-letter character
|
||||
// from the token. Otherwise, a pure-letter prefix like "HE" for "HELP-QUIET"
|
||||
// would suppress natural language that happens to share that prefix (#100007).
|
||||
if (/[^A-Z_]/.test(tokenUpper)) {
|
||||
return /[^A-Z_]/.test(normalized);
|
||||
}
|
||||
return tokenUpper === SILENT_REPLY_TOKEN && normalized === "NO";
|
||||
}
|
||||
|
||||
@@ -364,6 +364,10 @@ describe("isTransientUnhandledRejectionError", () => {
|
||||
const wrappedWsPreHandshakeClose = Object.assign(new Error("feishu reconnect failed"), {
|
||||
cause: wsPreHandshakeClose,
|
||||
});
|
||||
const undiciTerminated = new TypeError("terminated");
|
||||
const wrappedUndiciTerminated = Object.assign(new Error("model fetch failed"), {
|
||||
cause: undiciTerminated,
|
||||
});
|
||||
const generic = new Error("boom");
|
||||
|
||||
expect(isBenignUncaughtExceptionError(epipe)).toBe(true);
|
||||
@@ -380,6 +384,10 @@ describe("isTransientUnhandledRejectionError", () => {
|
||||
expect(isBenignUncaughtExceptionError(new Error("ERR_HTTP2_INVALID_SESSION"))).toBe(true);
|
||||
expect(isBenignUncaughtExceptionError(wsPreHandshakeClose)).toBe(true);
|
||||
expect(isBenignUncaughtExceptionError(wrappedWsPreHandshakeClose)).toBe(true);
|
||||
expect(isBenignUncaughtExceptionError(undiciTerminated)).toBe(true);
|
||||
expect(isBenignUncaughtExceptionError(wrappedUndiciTerminated)).toBe(true);
|
||||
expect(isBenignUncaughtExceptionError(new Error("terminated"))).toBe(false);
|
||||
expect(isBenignUncaughtExceptionError(new TypeError("terminated unexpectedly"))).toBe(false);
|
||||
expect(
|
||||
isBenignUncaughtExceptionError(
|
||||
new Error("WebSocket error: WebSocket was closed before the connection was established"),
|
||||
|
||||
@@ -114,6 +114,7 @@ const TRANSIENT_NETWORK_MESSAGE_CODE_RE =
|
||||
const BENIGN_UNCAUGHT_EXCEPTION_NETWORK_MESSAGE_CODE_RE =
|
||||
/\b(ECONNREFUSED|ENETDOWN|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT|ERR_HTTP2_INVALID_SESSION)\b/i;
|
||||
const WS_PRE_HANDSHAKE_CLOSE_MESSAGE = "websocket was closed before the connection was established";
|
||||
const UNDICI_TERMINATED_TYPE_ERROR_MESSAGE = "terminated";
|
||||
|
||||
const TRANSIENT_SQLITE_MESSAGE_CODE_RE =
|
||||
/\b(SQLITE_BUSY|SQLITE_CANTOPEN|SQLITE_IOERR|SQLITE_LOCKED)\b/i;
|
||||
@@ -422,6 +423,15 @@ export function isTransientUnhandledRejectionError(err: unknown): boolean {
|
||||
|
||||
function isBenignUncaughtNetworkException(err: unknown): boolean {
|
||||
for (const candidate of collectNestedUnhandledErrorCandidates(err)) {
|
||||
// Undici emits this bare TypeError when a response body aborts after request start.
|
||||
// Keep the shape exact so unrelated "terminated" errors still take the fatal path.
|
||||
if (
|
||||
candidate instanceof TypeError &&
|
||||
normalizeLowercaseStringOrEmpty(candidate.message) === UNDICI_TERMINATED_TYPE_ERROR_MESSAGE
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const code = extractErrorCodeOrErrno(candidate);
|
||||
if (code && BENIGN_UNCAUGHT_EXCEPTION_NETWORK_CODES.has(code)) {
|
||||
return true;
|
||||
|
||||
@@ -34,6 +34,10 @@ type BridgeInternals = {
|
||||
event: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}) => Promise<void>;
|
||||
dispatchGatewayEvent: (event: {
|
||||
event: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}) => Promise<void>;
|
||||
handleSessionMessageEvent: (payload: {
|
||||
sessionKey: string;
|
||||
senderIsOwner?: boolean;
|
||||
@@ -304,6 +308,52 @@ describe("OpenClawChannelBridge — pendingClaudePermissions / pendingApprovals
|
||||
}
|
||||
});
|
||||
|
||||
test("a rejected gateway event still emits exactly one diagnostic record with verbose off", async () => {
|
||||
const bridge = makeBridge(false);
|
||||
const writes: string[] = [];
|
||||
const writeSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
writes.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(bridge, "handleGatewayEvent").mockRejectedValue(new Error("handler boom"));
|
||||
try {
|
||||
await bridge.dispatchGatewayEvent({ event: "exec.approval.requested", payload: {} });
|
||||
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]).toBe("openclaw mcp: gateway event exec.approval.requested failed\n");
|
||||
expect(writes[0]).not.toContain("handler boom");
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a rejected gateway event includes error detail with verbose on", async () => {
|
||||
const bridge = makeBridge(true);
|
||||
const writes: string[] = [];
|
||||
const writeSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
writes.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(bridge, "handleGatewayEvent").mockRejectedValue(new Error("handler boom"));
|
||||
try {
|
||||
await bridge.dispatchGatewayEvent({ event: "exec.approval.requested", payload: {} });
|
||||
|
||||
expect(writes).toHaveLength(2);
|
||||
expect(writes[0]).toBe("openclaw mcp: gateway event exec.approval.requested failed\n");
|
||||
expect(writes[1]).toBe(
|
||||
"openclaw mcp: gateway event exec.approval.requested error: Error: handler boom\n",
|
||||
);
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("sweeper interval is not started before any pending entry is added", async () => {
|
||||
const bridge = makeBridge();
|
||||
try {
|
||||
|
||||
@@ -148,7 +148,7 @@ export class OpenClawChannelBridge {
|
||||
scopes: [READ_SCOPE, WRITE_SCOPE, APPROVALS_SCOPE],
|
||||
requestTimeoutMs: 180_000,
|
||||
onEvent: (event) => {
|
||||
void this.handleGatewayEvent(event);
|
||||
void this.dispatchGatewayEvent(event);
|
||||
},
|
||||
onHelloOk: () => {
|
||||
this.retryingInitialConnect = false;
|
||||
@@ -521,6 +521,21 @@ export class OpenClawChannelBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchGatewayEvent(event: EventFrame): Promise<void> {
|
||||
try {
|
||||
await this.handleGatewayEvent(event);
|
||||
} catch (error) {
|
||||
// Always surface a single low-noise record so swallowed gateway event
|
||||
// failures remain observable; the spammy error detail stays behind --verbose.
|
||||
process.stderr.write(`openclaw mcp: gateway event ${event.event} failed\n`);
|
||||
if (this.verbose) {
|
||||
process.stderr.write(
|
||||
`openclaw mcp: gateway event ${event.event} error: ${String(error)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGatewayEvent(event: EventFrame): Promise<void> {
|
||||
switch (event.event) {
|
||||
case "session.message":
|
||||
|
||||
@@ -88,6 +88,20 @@ describe("sendTranscriptEcho", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps dollar sequences in the transcript literal", async () => {
|
||||
await sendTranscriptEcho({
|
||||
ctx: createCtx(),
|
||||
cfg: EMPTY_CONFIG,
|
||||
transcript: "tickets cost $$40, wait for the deal & confirm with $&",
|
||||
});
|
||||
|
||||
expect(mockDeliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payloads: [{ text: '📝 "tickets cost $$40, wait for the deal & confirm with $&"' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips non-deliverable channels", async () => {
|
||||
await sendTranscriptEcho({
|
||||
ctx: createCtx({ Provider: "internal-system", From: "some-source" }),
|
||||
|
||||
@@ -15,7 +15,9 @@ const loadMessageRuntime = createLazyRuntimeModule(() => import("../channels/mes
|
||||
export const DEFAULT_ECHO_TRANSCRIPT_FORMAT = '📝 "{transcript}"';
|
||||
|
||||
function formatEchoTranscript(transcript: string, format: string): string {
|
||||
return format.replace("{transcript}", transcript);
|
||||
// Function replacer keeps `$` sequences in the transcript literal instead of
|
||||
// being parsed as String.prototype.replace substitution patterns.
|
||||
return format.replace("{transcript}", () => transcript);
|
||||
}
|
||||
|
||||
/** Sends a best-effort transcript echo back to the originating deliverable chat. */
|
||||
|
||||
@@ -20,6 +20,26 @@ afterEach(() => {
|
||||
cliBackendsTesting.resetDepsForTest();
|
||||
});
|
||||
|
||||
describe("buildStatusMessage current time", () => {
|
||||
it("surfaces a live current-time line so session_status returns the date/time", () => {
|
||||
// 2025-07-03T08:00:00Z; the Reference UTC line is timezone-independent.
|
||||
const now = 1_751_529_600_000;
|
||||
const text = buildStatusMessage({
|
||||
now,
|
||||
config: { agents: { defaults: { userTimezone: "UTC", timeFormat: "24" } } },
|
||||
agent: { model: "anthropic/claude-haiku-4-5" },
|
||||
sessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "steer", depth: 0 },
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
expect(text).toContain("Current time:");
|
||||
expect(text).toContain("(UTC)");
|
||||
expect(text).toContain("Reference UTC: 2025-07-03 08:00 UTC");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStatusMessage context window", () => {
|
||||
it("ignores stale runtime context after a manual session model switch", () => {
|
||||
const text = buildStatusMessage({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveContextTokensForModel } from "../agents/context.js";
|
||||
import { resolveCronStyleNow } from "../agents/current-time.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import { resolveExtraParams } from "../agents/embedded-agent-runner/extra-params.js";
|
||||
import { resolveFastModeState } from "../agents/fast-mode.js";
|
||||
@@ -563,6 +564,10 @@ function hasUserPinnedModelSelection(entry: SessionEntry | undefined): boolean {
|
||||
|
||||
export function buildStatusMessage(args: StatusArgs): string {
|
||||
const now = args.now ?? Date.now();
|
||||
// Derive the live wall clock here so both /status and session_status expose
|
||||
// the same configured timezone without duplicating formatting at each caller.
|
||||
const timeLine =
|
||||
args.timeLine ?? (args.config ? resolveCronStyleNow(args.config, now).timeLine : undefined);
|
||||
const entry = args.sessionEntry;
|
||||
const selectionConfig = {
|
||||
agents: {
|
||||
@@ -1113,7 +1118,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
|
||||
return [
|
||||
versionLine,
|
||||
args.timeLine,
|
||||
timeLine,
|
||||
args.uptimeLine,
|
||||
...modelLines,
|
||||
configuredFallbacksLine,
|
||||
|
||||
@@ -194,6 +194,58 @@ NODE
|
||||
expect(result.stdout).not.toContain("Installing Node.js via NodeSource");
|
||||
});
|
||||
|
||||
it("ignores an unrelated pacman command on Debian", () => {
|
||||
const result = runInstallShell(`
|
||||
set -euo pipefail
|
||||
source "${SCRIPT_PATH}"
|
||||
OS=linux
|
||||
require_sudo() { :; }
|
||||
install_build_tools_linux() { return 0; }
|
||||
is_root() { return 0; }
|
||||
is_arch_linux() { return 1; }
|
||||
is_alpine_linux() { return 1; }
|
||||
pacman() { printf 'pacman:%s\\n' "$*"; }
|
||||
apt-get() { :; }
|
||||
ui_info() { printf 'info:%s\\n' "$*"; }
|
||||
ui_success() { :; }
|
||||
run_required_step() { printf 'step:%s|%s\\n' "$1" "\${*:2}"; }
|
||||
finish_linux_node_install() { printf 'finish-linux-node\\n'; }
|
||||
install_node
|
||||
`);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("info:Installing Node.js via NodeSource");
|
||||
expect(result.stdout).toContain("step:Installing Node.js|apt_get_install nodejs");
|
||||
expect(result.stdout).toContain("finish-linux-node");
|
||||
expect(result.stdout).not.toContain("pacman:");
|
||||
});
|
||||
|
||||
it("uses pacman for Node.js on Arch Linux", () => {
|
||||
const result = runInstallShell(`
|
||||
set -euo pipefail
|
||||
source "${SCRIPT_PATH}"
|
||||
OS=linux
|
||||
require_sudo() { :; }
|
||||
install_build_tools_linux() { return 0; }
|
||||
is_root() { return 0; }
|
||||
is_arch_linux() { return 0; }
|
||||
pacman() { :; }
|
||||
ui_info() { printf 'info:%s\\n' "$*"; }
|
||||
ui_success() { :; }
|
||||
run_required_step() { printf 'step:%s|%s\\n' "$1" "\${*:2}"; }
|
||||
finish_linux_node_install() { printf 'finish-linux-node\\n'; }
|
||||
install_node
|
||||
`);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain(
|
||||
"info:Installing Node.js via pacman (Arch-based distribution detected)",
|
||||
);
|
||||
expect(result.stdout).toContain("step:Installing Node.js|pacman -Sy --noconfirm nodejs npm");
|
||||
expect(result.stdout).toContain("finish-linux-node");
|
||||
expect(result.stdout).not.toContain("Installing Node.js via NodeSource");
|
||||
});
|
||||
|
||||
it("tries nodejs-current when Alpine nodejs is below the runtime floor", () => {
|
||||
const result = runInstallShell(`
|
||||
set -euo pipefail
|
||||
|
||||
Reference in New Issue
Block a user