refactor: dedupe agent and browser cli helpers

This commit is contained in:
Peter Steinberger
2026-03-03 00:14:48 +00:00
parent fe14be2352
commit fd3ca8a34c
46 changed files with 1051 additions and 1117 deletions

View File

@@ -126,6 +126,35 @@ describe("resolveAcpClientSpawnInvocation", () => {
});
describe("resolvePermissionRequest", () => {
async function expectPromptReject(params: {
request: Partial<RequestPermissionRequest>;
expectedToolName: string | undefined;
expectedTitle: string;
}) {
const prompt = vi.fn(async () => false);
const res = await resolvePermissionRequest(makePermissionRequest(params.request), {
prompt,
log: () => {},
});
expect(prompt).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledWith(params.expectedToolName, params.expectedTitle);
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
}
async function expectAutoAllowWithoutPrompt(params: {
request: Partial<RequestPermissionRequest>;
cwd?: string;
}) {
const prompt = vi.fn(async () => true);
const res = await resolvePermissionRequest(makePermissionRequest(params.request), {
prompt,
log: () => {},
cwd: params.cwd,
});
expect(prompt).not.toHaveBeenCalled();
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
}
it("auto-approves safe tools without prompting", async () => {
const prompt = vi.fn(async () => true);
const res = await resolvePermissionRequest(makePermissionRequest(), { prompt, log: () => {} });
@@ -185,37 +214,31 @@ describe("resolvePermissionRequest", () => {
});
it("auto-approves read when rawInput path resolves inside cwd", async () => {
const prompt = vi.fn(async () => true);
const res = await resolvePermissionRequest(
makePermissionRequest({
await expectAutoAllowWithoutPrompt({
request: {
toolCall: {
toolCallId: "tool-read-inside-cwd",
title: "read: ignored-by-raw-input",
status: "pending",
rawInput: { path: "docs/security.md" },
},
}),
{ prompt, log: () => {}, cwd: "/tmp/openclaw-acp-cwd" },
);
expect(prompt).not.toHaveBeenCalled();
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
},
cwd: "/tmp/openclaw-acp-cwd",
});
});
it("auto-approves read when rawInput file URL resolves inside cwd", async () => {
const prompt = vi.fn(async () => true);
const res = await resolvePermissionRequest(
makePermissionRequest({
await expectAutoAllowWithoutPrompt({
request: {
toolCall: {
toolCallId: "tool-read-inside-cwd-file-url",
title: "read: ignored-by-raw-input",
status: "pending",
rawInput: { path: "file:///tmp/openclaw-acp-cwd/docs/security.md" },
},
}),
{ prompt, log: () => {}, cwd: "/tmp/openclaw-acp-cwd" },
);
expect(prompt).not.toHaveBeenCalled();
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
},
cwd: "/tmp/openclaw-acp-cwd",
});
});
it("prompts for read when rawInput path escapes cwd via traversal", async () => {
@@ -343,56 +366,47 @@ describe("resolvePermissionRequest", () => {
});
it("prompts when metadata tool name contains invalid characters", async () => {
const prompt = vi.fn(async () => false);
const res = await resolvePermissionRequest(
makePermissionRequest({
await expectPromptReject({
request: {
toolCall: {
toolCallId: "tool-invalid-meta",
title: "read: src/index.ts",
status: "pending",
_meta: { toolName: "read.*" },
},
}),
{ prompt, log: () => {} },
);
expect(prompt).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledWith(undefined, "read: src/index.ts");
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
},
expectedToolName: undefined,
expectedTitle: "read: src/index.ts",
});
});
it("prompts when raw input tool name exceeds max length", async () => {
const prompt = vi.fn(async () => false);
const res = await resolvePermissionRequest(
makePermissionRequest({
await expectPromptReject({
request: {
toolCall: {
toolCallId: "tool-long-raw",
title: "read: src/index.ts",
status: "pending",
rawInput: { toolName: "r".repeat(129) },
},
}),
{ prompt, log: () => {} },
);
expect(prompt).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledWith(undefined, "read: src/index.ts");
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
},
expectedToolName: undefined,
expectedTitle: "read: src/index.ts",
});
});
it("prompts when title tool name contains non-allowed characters", async () => {
const prompt = vi.fn(async () => false);
const res = await resolvePermissionRequest(
makePermissionRequest({
await expectPromptReject({
request: {
toolCall: {
toolCallId: "tool-bad-title-name",
title: "read🚀: src/index.ts",
status: "pending",
},
}),
{ prompt, log: () => {} },
);
expect(prompt).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledWith(undefined, "read🚀: src/index.ts");
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
},
expectedToolName: undefined,
expectedTitle: "read🚀: src/index.ts",
});
});
it("returns cancelled when no permission options are present", async () => {

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js";
vi.mock("./tools/gateway.js", () => ({
callGatewayTool: vi.fn(),
@@ -38,20 +39,7 @@ function buildPreparedSystemRunPayload(rawInvokeParams: unknown) {
};
};
const params = invoke.params ?? {};
const argv = Array.isArray(params.command) ? params.command.map(String) : [];
const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand : null;
return {
payload: {
cmdText: rawCommand ?? argv.join(" "),
plan: {
argv,
cwd: typeof params.cwd === "string" ? params.cwd : null,
rawCommand,
agentId: typeof params.agentId === "string" ? params.agentId : null,
sessionKey: typeof params.sessionKey === "string" ? params.sessionKey : null,
},
},
};
return buildSystemRunPreparePayload(params);
}
describe("exec approvals", () => {

View File

@@ -2,6 +2,10 @@ import { completeSimple, type Model } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
import { BYTEPLUS_CODING_BASE_URL, BYTEPLUS_DEFAULT_COST } from "./byteplus-models.js";
import {
createSingleUserPromptMessage,
extractNonEmptyAssistantText,
} from "./live-test-helpers.js";
const BYTEPLUS_KEY = process.env.BYTEPLUS_API_KEY ?? "";
const BYTEPLUS_CODING_MODEL = process.env.BYTEPLUS_CODING_MODEL?.trim() || "ark-code-latest";
@@ -27,21 +31,12 @@ describeLive("byteplus coding plan live", () => {
const res = await completeSimple(
model,
{
messages: [
{
role: "user",
content: "Reply with the word ok.",
timestamp: Date.now(),
},
],
messages: createSingleUserPromptMessage(),
},
{ apiKey: BYTEPLUS_KEY, maxTokens: 64 },
);
const text = res.content
.filter((block) => block.type === "text")
.map((block) => block.text.trim())
.join(" ");
const text = extractNonEmptyAssistantText(res.content);
expect(text.length).toBeGreaterThan(0);
}, 30000);
});

View File

@@ -0,0 +1,24 @@
export const LIVE_OK_PROMPT = "Reply with the word ok.";
export function createSingleUserPromptMessage(content = LIVE_OK_PROMPT) {
return [
{
role: "user" as const,
content,
timestamp: Date.now(),
},
];
}
export function extractNonEmptyAssistantText(
content: Array<{
type?: string;
text?: string;
}>,
) {
return content
.filter((block) => block.type === "text")
.map((block) => block.text?.trim() ?? "")
.filter(Boolean)
.join(" ");
}

View File

@@ -32,6 +32,14 @@ describe("Ollama auto-discovery", () => {
originalFetch = globalThis.fetch;
}
function mockOllamaUnreachable() {
globalThis.fetch = vi
.fn()
.mockRejectedValue(
new Error("connect ECONNREFUSED 127.0.0.1:11434"),
) as unknown as typeof fetch;
}
it("auto-registers ollama provider when models are discovered locally", async () => {
setupDiscoveryEnv();
globalThis.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
@@ -62,11 +70,7 @@ describe("Ollama auto-discovery", () => {
it("does not warn when Ollama is unreachable and not explicitly configured", async () => {
setupDiscoveryEnv();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
globalThis.fetch = vi
.fn()
.mockRejectedValue(
new Error("connect ECONNREFUSED 127.0.0.1:11434"),
) as unknown as typeof fetch;
mockOllamaUnreachable();
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const providers = await resolveImplicitProviders({ agentDir });
@@ -82,11 +86,7 @@ describe("Ollama auto-discovery", () => {
it("warns when Ollama is unreachable and explicitly configured", async () => {
setupDiscoveryEnv();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
globalThis.fetch = vi
.fn()
.mockRejectedValue(
new Error("connect ECONNREFUSED 127.0.0.1:11434"),
) as unknown as typeof fetch;
mockOllamaUnreachable();
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await resolveImplicitProviders({

View File

@@ -1,6 +1,10 @@
import { completeSimple, type Model } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
import {
createSingleUserPromptMessage,
extractNonEmptyAssistantText,
} from "./live-test-helpers.js";
const MOONSHOT_KEY = process.env.MOONSHOT_API_KEY ?? "";
const MOONSHOT_BASE_URL = process.env.MOONSHOT_BASE_URL?.trim() || "https://api.moonshot.ai/v1";
@@ -27,21 +31,12 @@ describeLive("moonshot live", () => {
const res = await completeSimple(
model,
{
messages: [
{
role: "user",
content: "Reply with the word ok.",
timestamp: Date.now(),
},
],
messages: createSingleUserPromptMessage(),
},
{ apiKey: MOONSHOT_KEY, maxTokens: 64 },
);
const text = res.content
.filter((block) => block.type === "text")
.map((block) => block.text.trim())
.join(" ");
const text = extractNonEmptyAssistantText(res.content);
expect(text.length).toBeGreaterThan(0);
}, 30000);
});

View File

@@ -171,6 +171,20 @@ function buildManager(opts?: ConstructorParameters<typeof OpenAIWebSocketManager
});
}
function attachErrorCollector(manager: OpenAIWebSocketManager) {
const errors: Error[] = [];
manager.on("error", (e) => errors.push(e));
return errors;
}
async function connectManagerAndGetSocket(manager: OpenAIWebSocketManager) {
const connectPromise = manager.connect("sk-test");
const sock = lastSocket();
sock.simulateOpen();
await connectPromise;
return sock;
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
@@ -576,13 +590,8 @@ describe("OpenAIWebSocketManager", () => {
describe("error handling", () => {
it("emits error event on malformed JSON message", async () => {
const manager = buildManager();
const p = manager.connect("sk-test");
const sock = lastSocket();
sock.simulateOpen();
await p;
const errors: Error[] = [];
manager.on("error", (e) => errors.push(e));
const sock = await connectManagerAndGetSocket(manager);
const errors = attachErrorCollector(manager);
sock.emit("message", Buffer.from("not valid json{{{{"));
@@ -592,13 +601,8 @@ describe("OpenAIWebSocketManager", () => {
it("emits error event when message has no type field", async () => {
const manager = buildManager();
const p = manager.connect("sk-test");
const sock = lastSocket();
sock.simulateOpen();
await p;
const errors: Error[] = [];
manager.on("error", (e) => errors.push(e));
const sock = await connectManagerAndGetSocket(manager);
const errors = attachErrorCollector(manager);
sock.emit("message", Buffer.from(JSON.stringify({ foo: "bar" })));
@@ -611,9 +615,7 @@ describe("OpenAIWebSocketManager", () => {
const p = manager.connect("sk-test").catch(() => {
/* ignore rejection */
});
const errors: Error[] = [];
manager.on("error", (e) => errors.push(e));
const errors = attachErrorCollector(manager);
lastSocket().simulateError(new Error("SSL handshake failed"));
await p;
@@ -626,9 +628,7 @@ describe("OpenAIWebSocketManager", () => {
const p = manager.connect("sk-test").catch(() => {
/* ignore rejection */
});
const errors: Error[] = [];
manager.on("error", (e) => errors.push(e));
const errors = attachErrorCollector(manager);
// Fire two errors in quick succession — previously the second would
// be unhandled because .once("error") removed the handler after #1.

View File

@@ -107,6 +107,24 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
expect(getChildSessionKey()?.startsWith(`agent:${params.agentId}:subagent:`)).toBe(true);
}
async function expectInvalidAgentId(callId: string, agentId: string) {
setSessionsSpawnConfigOverride({
session: { mainKey: "main", scope: "per-sender" },
agents: {
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute(callId, { task: "do thing", agentId });
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain("Invalid agentId");
expect(callGatewayMock).not.toHaveBeenCalled();
}
beforeEach(() => {
resetSessionsSpawnConfigOverride();
resetSubagentRegistryForTests();
@@ -237,45 +255,11 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
});
it("rejects agentId containing path separators (#31311)", async () => {
setSessionsSpawnConfigOverride({
session: { mainKey: "main", scope: "per-sender" },
agents: {
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call-path", {
task: "do thing",
agentId: "../../../etc/passwd",
});
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain("Invalid agentId");
expect(callGatewayMock).not.toHaveBeenCalled();
await expectInvalidAgentId("call-path", "../../../etc/passwd");
});
it("rejects agentId exceeding 64 characters (#31311)", async () => {
setSessionsSpawnConfigOverride({
session: { mainKey: "main", scope: "per-sender" },
agents: {
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call-long", {
task: "do thing",
agentId: "a".repeat(65),
});
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain("Invalid agentId");
expect(callGatewayMock).not.toHaveBeenCalled();
await expectInvalidAgentId("call-long", "a".repeat(65));
});
it("accepts well-formed agentId with hyphens and underscores (#31311)", async () => {

View File

@@ -9,6 +9,16 @@ type RelativePathOptions = {
includeRootInError?: boolean;
};
function throwPathEscapesBoundary(params: {
options?: RelativePathOptions;
rootResolved: string;
candidate: string;
}): never {
const boundary = params.options?.boundaryLabel ?? "workspace root";
const suffix = params.options?.includeRootInError ? ` (${params.rootResolved})` : "";
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
}
function toRelativePathUnderRoot(params: {
root: string;
candidate: string;
@@ -29,14 +39,18 @@ function toRelativePathUnderRoot(params: {
if (params.options?.allowRoot) {
return "";
}
const boundary = params.options?.boundaryLabel ?? "workspace root";
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
throwPathEscapesBoundary({
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
if (relative.startsWith("..") || path.win32.isAbsolute(relative)) {
const boundary = params.options?.boundaryLabel ?? "workspace root";
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
throwPathEscapesBoundary({
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
return relative;
}
@@ -48,14 +62,18 @@ function toRelativePathUnderRoot(params: {
if (params.options?.allowRoot) {
return "";
}
const boundary = params.options?.boundaryLabel ?? "workspace root";
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
throwPathEscapesBoundary({
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
if (relative.startsWith("..") || path.isAbsolute(relative)) {
const boundary = params.options?.boundaryLabel ?? "workspace root";
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
throwPathEscapesBoundary({
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
return relative;
}

View File

@@ -1,6 +1,29 @@
import { describe, expect, it } from "vitest";
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
function createFlushOnParagraphChunker(params: { minChars: number; maxChars: number }) {
return new EmbeddedBlockChunker({
minChars: params.minChars,
maxChars: params.maxChars,
breakPreference: "paragraph",
flushOnParagraph: true,
});
}
function drainChunks(chunker: EmbeddedBlockChunker) {
const chunks: string[] = [];
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
return chunks;
}
function expectFlushAtFirstParagraphBreak(text: string) {
const chunker = createFlushOnParagraphChunker({ minChars: 100, maxChars: 200 });
chunker.append(text);
const chunks = drainChunks(chunker);
expect(chunks).toEqual(["First paragraph."]);
expect(chunker.bufferedText).toBe("Second paragraph.");
}
describe("EmbeddedBlockChunker", () => {
it("breaks at paragraph boundary right after fence close", () => {
const chunker = new EmbeddedBlockChunker({
@@ -21,8 +44,7 @@ describe("EmbeddedBlockChunker", () => {
chunker.append(text);
const chunks: string[] = [];
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
const chunks = drainChunks(chunker);
expect(chunks.length).toBe(1);
expect(chunks[0]).toContain("console.log");
@@ -32,37 +54,11 @@ describe("EmbeddedBlockChunker", () => {
});
it("flushes paragraph boundaries before minChars when flushOnParagraph is set", () => {
const chunker = new EmbeddedBlockChunker({
minChars: 100,
maxChars: 200,
breakPreference: "paragraph",
flushOnParagraph: true,
});
chunker.append("First paragraph.\n\nSecond paragraph.");
const chunks: string[] = [];
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
expect(chunks).toEqual(["First paragraph."]);
expect(chunker.bufferedText).toBe("Second paragraph.");
expectFlushAtFirstParagraphBreak("First paragraph.\n\nSecond paragraph.");
});
it("treats blank lines with whitespace as paragraph boundaries when flushOnParagraph is set", () => {
const chunker = new EmbeddedBlockChunker({
minChars: 100,
maxChars: 200,
breakPreference: "paragraph",
flushOnParagraph: true,
});
chunker.append("First paragraph.\n \nSecond paragraph.");
const chunks: string[] = [];
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
expect(chunks).toEqual(["First paragraph."]);
expect(chunker.bufferedText).toBe("Second paragraph.");
expectFlushAtFirstParagraphBreak("First paragraph.\n \nSecond paragraph.");
});
it("falls back to maxChars when flushOnParagraph is set and no paragraph break exists", () => {
@@ -75,8 +71,7 @@ describe("EmbeddedBlockChunker", () => {
chunker.append("abcdefghijKLMNOP");
const chunks: string[] = [];
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
const chunks = drainChunks(chunker);
expect(chunks).toEqual(["abcdefghij"]);
expect(chunker.bufferedText).toBe("KLMNOP");
@@ -92,8 +87,7 @@ describe("EmbeddedBlockChunker", () => {
chunker.append("abcdefghijk\n\nRest");
const chunks: string[] = [];
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
const chunks = drainChunks(chunker);
expect(chunks.every((chunk) => chunk.length <= 10)).toBe(true);
expect(chunks).toEqual(["abcdefghij", "k"]);
@@ -121,8 +115,7 @@ describe("EmbeddedBlockChunker", () => {
chunker.append(text);
const chunks: string[] = [];
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
const chunks = drainChunks(chunker);
expect(chunks).toEqual(["Intro\n```js\nconst a = 1;\n\nconst b = 2;\n```"]);
expect(chunker.bufferedText).toBe("After fence");

View File

@@ -200,7 +200,7 @@ function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[]
return touched ? out : messages;
}
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
export function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
if (!schema || typeof schema !== "object") {
return [];
}

View File

@@ -482,40 +482,39 @@ describe("compaction-safeguard double-compaction guard", () => {
});
});
async function expectWorkspaceSummaryEmptyForAgentsAlias(
createAlias: (outsidePath: string, agentsPath: string) => void,
) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
const prevCwd = process.cwd();
try {
const outside = path.join(root, "outside-secret.txt");
fs.writeFileSync(outside, "secret");
createAlias(outside, path.join(root, "AGENTS.md"));
process.chdir(root);
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
} finally {
process.chdir(prevCwd);
fs.rmSync(root, { recursive: true, force: true });
}
}
describe("readWorkspaceContextForSummary", () => {
it.runIf(process.platform !== "win32")(
"returns empty when AGENTS.md is a symlink escape",
async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
const prevCwd = process.cwd();
try {
const outside = path.join(root, "outside-secret.txt");
fs.writeFileSync(outside, "secret");
fs.symlinkSync(outside, path.join(root, "AGENTS.md"));
process.chdir(root);
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
} finally {
process.chdir(prevCwd);
fs.rmSync(root, { recursive: true, force: true });
}
await expectWorkspaceSummaryEmptyForAgentsAlias((outside, agentsPath) => {
fs.symlinkSync(outside, agentsPath);
});
},
);
it.runIf(process.platform !== "win32")(
"returns empty when AGENTS.md is a hardlink alias",
async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
const prevCwd = process.cwd();
try {
const outside = path.join(root, "outside-secret.txt");
fs.writeFileSync(outside, "secret");
fs.linkSync(outside, path.join(root, "AGENTS.md"));
process.chdir(root);
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
} finally {
process.chdir(prevCwd);
fs.rmSync(root, { recursive: true, force: true });
}
await expectWorkspaceSummaryEmptyForAgentsAlias((outside, agentsPath) => {
fs.linkSync(outside, agentsPath);
});
},
);
});

View File

@@ -28,6 +28,16 @@ describe("Agent-specific tool filtering", () => {
stat: async () => null,
};
function expectReadOnlyToolSet(toolNames: string[], extraDenied: string[] = []) {
expect(toolNames).toContain("read");
expect(toolNames).not.toContain("exec");
expect(toolNames).not.toContain("write");
expect(toolNames).not.toContain("apply_patch");
for (const toolName of extraDenied) {
expect(toolNames).not.toContain(toolName);
}
}
async function withApplyPatchEscapeCase(
opts: { workspaceOnly?: boolean },
run: (params: {
@@ -250,12 +260,10 @@ describe("Agent-specific tool filtering", () => {
agentDir: "/tmp/agent-restricted",
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("read");
expect(toolNames).not.toContain("exec");
expect(toolNames).not.toContain("write");
expect(toolNames).not.toContain("apply_patch");
expect(toolNames).not.toContain("edit");
expectReadOnlyToolSet(
tools.map((t) => t.name),
["edit"],
);
});
it("should apply provider-specific tool policy", () => {
@@ -279,11 +287,7 @@ describe("Agent-specific tool filtering", () => {
modelId: "claude-opus-4-6-thinking",
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("read");
expect(toolNames).not.toContain("exec");
expect(toolNames).not.toContain("write");
expect(toolNames).not.toContain("apply_patch");
expectReadOnlyToolSet(tools.map((t) => t.name));
});
it("should apply provider-specific tool profile overrides", () => {

View File

@@ -6,6 +6,7 @@ import { Type } from "@sinclair/typebox";
import { describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createOpenClawTools } from "./openclaw-tools.js";
import { findUnsupportedSchemaKeywords } from "./pi-embedded-runner/google.js";
import { __testing, createOpenClawCodingTools } from "./pi-tools.js";
import { createOpenClawReadTool, createSandboxedReadTool } from "./pi-tools.read.js";
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
@@ -444,75 +445,12 @@ describe("createOpenClawCodingTools", () => {
expect(names.has("read")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
// Helper to recursively check schema for unsupported keywords
const unsupportedKeywords = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
"examples",
"minLength",
"maxLength",
"minimum",
"maximum",
"multipleOf",
"pattern",
"format",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
]);
const findUnsupportedKeywords = (schema: unknown, path: string): string[] => {
const found: string[] = [];
if (!schema || typeof schema !== "object") {
return found;
}
if (Array.isArray(schema)) {
schema.forEach((item, i) => {
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
});
return found;
}
const record = schema as Record<string, unknown>;
const properties =
record.properties &&
typeof record.properties === "object" &&
!Array.isArray(record.properties)
? (record.properties as Record<string, unknown>)
: undefined;
if (properties) {
for (const [key, value] of Object.entries(properties)) {
found.push(...findUnsupportedKeywords(value, `${path}.properties.${key}`));
}
}
for (const [key, value] of Object.entries(record)) {
if (key === "properties") {
continue;
}
if (unsupportedKeywords.has(key)) {
found.push(`${path}.${key}`);
}
if (value && typeof value === "object") {
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
}
}
return found;
};
const googleTools = createOpenClawCodingTools({
modelProvider: "google",
senderIsOwner: true,
});
for (const tool of googleTools) {
const violations = findUnsupportedKeywords(tool.parameters, `${tool.name}.parameters`);
const violations = findUnsupportedSchemaKeywords(tool.parameters, `${tool.name}.parameters`);
expect(violations).toEqual([]);
}
});

View File

@@ -763,6 +763,12 @@ function createSandboxEditOperations(params: SandboxToolParams) {
} as const;
}
async function writeHostFile(absolutePath: string, content: string) {
const resolved = path.resolve(absolutePath);
await fs.mkdir(path.dirname(resolved), { recursive: true });
await fs.writeFile(resolved, content, "utf-8");
}
function createHostWriteOperations(root: string, options?: { workspaceOnly?: boolean }) {
const workspaceOnly = options?.workspaceOnly ?? false;
@@ -773,12 +779,7 @@ function createHostWriteOperations(root: string, options?: { workspaceOnly?: boo
const resolved = path.resolve(dir);
await fs.mkdir(resolved, { recursive: true });
},
writeFile: async (absolutePath: string, content: string) => {
const resolved = path.resolve(absolutePath);
const dir = path.dirname(resolved);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(resolved, content, "utf-8");
},
writeFile: writeHostFile,
} as const;
}
@@ -812,12 +813,7 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool
const resolved = path.resolve(absolutePath);
return await fs.readFile(resolved);
},
writeFile: async (absolutePath: string, content: string) => {
const resolved = path.resolve(absolutePath);
const dir = path.dirname(resolved);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(resolved, content, "utf-8");
},
writeFile: writeHostFile,
access: async (absolutePath: string) => {
const resolved = path.resolve(absolutePath);
await fs.access(resolved);

View File

@@ -21,6 +21,35 @@ async function withTempDir<T>(prefix: string, fn: (dir: string) => Promise<T>) {
}
}
function createExecTool(workspaceDir: string) {
const tools = createOpenClawCodingTools({
workspaceDir,
exec: { host: "gateway", ask: "off", security: "full" },
});
const execTool = tools.find((tool) => tool.name === "exec");
expect(execTool).toBeDefined();
return execTool;
}
async function expectExecCwdResolvesTo(
execTool: ReturnType<typeof createExecTool>,
callId: string,
params: { command: string; workdir?: string },
expectedDir: string,
) {
const result = await execTool?.execute(callId, params);
const cwd =
result?.details && typeof result.details === "object" && "cwd" in result.details
? (result.details as { cwd?: string }).cwd
: undefined;
expect(cwd).toBeTruthy();
const [resolvedOutput, resolvedExpected] = await Promise.all([
fs.realpath(String(cwd)),
fs.realpath(expectedDir),
]);
expect(resolvedOutput).toBe(resolvedExpected);
}
describe("workspace path resolution", () => {
it("resolves relative read/write/edit paths against workspaceDir even after cwd changes", async () => {
await withTempDir("openclaw-ws-", async (workspaceDir) => {
@@ -88,53 +117,21 @@ describe("workspace path resolution", () => {
it("defaults exec cwd to workspaceDir when workdir is omitted", async () => {
await withTempDir("openclaw-ws-", async (workspaceDir) => {
const tools = createOpenClawCodingTools({
workspaceDir,
exec: { host: "gateway", ask: "off", security: "full" },
});
const execTool = tools.find((tool) => tool.name === "exec");
expect(execTool).toBeDefined();
const result = await execTool?.execute("ws-exec", {
command: "echo ok",
});
const cwd =
result?.details && typeof result.details === "object" && "cwd" in result.details
? (result.details as { cwd?: string }).cwd
: undefined;
expect(cwd).toBeTruthy();
const [resolvedOutput, resolvedWorkspace] = await Promise.all([
fs.realpath(String(cwd)),
fs.realpath(workspaceDir),
]);
expect(resolvedOutput).toBe(resolvedWorkspace);
const execTool = createExecTool(workspaceDir);
await expectExecCwdResolvesTo(execTool, "ws-exec", { command: "echo ok" }, workspaceDir);
});
});
it("lets exec workdir override the workspace default", async () => {
await withTempDir("openclaw-ws-", async (workspaceDir) => {
await withTempDir("openclaw-override-", async (overrideDir) => {
const tools = createOpenClawCodingTools({
workspaceDir,
exec: { host: "gateway", ask: "off", security: "full" },
});
const execTool = tools.find((tool) => tool.name === "exec");
expect(execTool).toBeDefined();
const result = await execTool?.execute("ws-exec-override", {
command: "echo ok",
workdir: overrideDir,
});
const cwd =
result?.details && typeof result.details === "object" && "cwd" in result.details
? (result.details as { cwd?: string }).cwd
: undefined;
expect(cwd).toBeTruthy();
const [resolvedOutput, resolvedOverride] = await Promise.all([
fs.realpath(String(cwd)),
fs.realpath(overrideDir),
]);
expect(resolvedOutput).toBe(resolvedOverride);
const execTool = createExecTool(workspaceDir);
await expectExecCwdResolvesTo(
execTool,
"ws-exec-override",
{ command: "echo ok", workdir: overrideDir },
overrideDir,
);
});
});
});

View File

@@ -26,6 +26,31 @@ function appendToolResultText(sm: SessionManager, text: string) {
);
}
function appendAssistantToolCall(
sm: SessionManager,
params: { id: string; name: string; withArguments?: boolean },
) {
const toolCall: {
type: "toolCall";
id: string;
name: string;
arguments?: Record<string, never>;
} = {
type: "toolCall",
id: params.id,
name: params.name,
};
if (params.withArguments !== false) {
toolCall.arguments = {};
}
sm.appendMessage(
asAppendMessage({
role: "assistant",
content: [toolCall],
}),
);
}
function getPersistedMessages(sm: SessionManager): AgentMessage[] {
return sm
.getEntries()
@@ -273,19 +298,8 @@ describe("installSessionToolResultGuard", () => {
const sm = SessionManager.inMemory();
installSessionToolResultGuard(sm);
sm.appendMessage(
asAppendMessage({
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
}),
);
sm.appendMessage(
asAppendMessage({
role: "assistant",
content: [{ type: "toolCall", id: "call_2", name: "read" }],
}),
);
appendAssistantToolCall(sm, { id: "call_1", name: "read" });
appendAssistantToolCall(sm, { id: "call_2", name: "read", withArguments: false });
expectPersistedRoles(sm, ["assistant", "toolResult"]);
});
@@ -297,19 +311,8 @@ describe("installSessionToolResultGuard", () => {
allowedToolNames: ["read"],
});
sm.appendMessage(
asAppendMessage({
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
}),
);
sm.appendMessage(
asAppendMessage({
role: "assistant",
content: [{ type: "toolCall", id: "call_2", name: "write", arguments: {} }],
}),
);
appendAssistantToolCall(sm, { id: "call_1", name: "read" });
appendAssistantToolCall(sm, { id: "call_2", name: "write" });
expectPersistedRoles(sm, ["assistant"]);
expect(guard.getPendingIds()).toEqual([]);

View File

@@ -65,6 +65,28 @@ function mockAgentStartFailure() {
});
}
async function runSessionThreadSpawnAndGetError(params: {
toolCallId: string;
spawningResult: { status: "error"; error: string } | { status: "ok"; threadBindingReady: false };
}): Promise<{ error?: string; childSessionKey?: string }> {
hookRunnerMocks.runSubagentSpawning.mockResolvedValueOnce(params.spawningResult);
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "discord",
agentAccountId: "work",
agentTo: "channel:123",
});
const result = await tool.execute(params.toolCallId, {
task: "do thing",
runTimeoutSeconds: 1,
thread: true,
mode: "session",
});
expect(result.details).toMatchObject({ status: "error" });
return result.details as { error?: string; childSessionKey?: string };
}
describe("sessions_spawn subagent lifecycle hooks", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
@@ -214,26 +236,13 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
});
it("returns error when thread binding cannot be created", async () => {
hookRunnerMocks.runSubagentSpawning.mockResolvedValueOnce({
status: "error",
error: "Unable to create or bind a Discord thread for this subagent session.",
const details = await runSessionThreadSpawnAndGetError({
toolCallId: "call4",
spawningResult: {
status: "error",
error: "Unable to create or bind a Discord thread for this subagent session.",
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "discord",
agentAccountId: "work",
agentTo: "channel:123",
});
const result = await tool.execute("call4", {
task: "do thing",
runTimeoutSeconds: 1,
thread: true,
mode: "session",
});
expect(result.details).toMatchObject({ status: "error" });
const details = result.details as { error?: string; childSessionKey?: string };
expect(details.error).toMatch(/thread/i);
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
expectSessionsDeleteWithoutAgentStart();
@@ -245,26 +254,13 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
});
it("returns error when thread binding is not marked ready", async () => {
hookRunnerMocks.runSubagentSpawning.mockResolvedValueOnce({
status: "ok",
threadBindingReady: false,
const details = await runSessionThreadSpawnAndGetError({
toolCallId: "call4b",
spawningResult: {
status: "ok",
threadBindingReady: false,
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "discord",
agentAccountId: "work",
agentTo: "channel:123",
});
const result = await tool.execute("call4b", {
task: "do thing",
runTimeoutSeconds: 1,
thread: true,
mode: "session",
});
expect(result.details).toMatchObject({ status: "error" });
const details = result.details as { error?: string; childSessionKey?: string };
expect(details.error).toMatch(/unable to create or bind a thread/i);
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
expectSessionsDeleteWithoutAgentStart();

View File

@@ -28,15 +28,25 @@ describe("mapQueueOutcomeToDeliveryResult", () => {
});
describe("runSubagentAnnounceDispatch", () => {
it("uses queue-first ordering for non-completion mode", async () => {
const queue = vi.fn(async () => "none" as const);
const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const }));
async function runNonCompletionDispatch(params: {
queueOutcome: "none" | "queued" | "steered";
directDelivered?: boolean;
}) {
const queue = vi.fn(async () => params.queueOutcome);
const direct = vi.fn(async () => ({
delivered: params.directDelivered ?? true,
path: "direct" as const,
}));
const result = await runSubagentAnnounceDispatch({
expectsCompletionMessage: false,
queue,
direct,
});
return { queue, direct, result };
}
it("uses queue-first ordering for non-completion mode", async () => {
const { queue, direct, result } = await runNonCompletionDispatch({ queueOutcome: "none" });
expect(queue).toHaveBeenCalledTimes(1);
expect(direct).toHaveBeenCalledTimes(1);
@@ -49,14 +59,7 @@ describe("runSubagentAnnounceDispatch", () => {
});
it("short-circuits direct send when non-completion queue delivers", async () => {
const queue = vi.fn(async () => "queued" as const);
const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const }));
const result = await runSubagentAnnounceDispatch({
expectsCompletionMessage: false,
queue,
direct,
});
const { queue, direct, result } = await runNonCompletionDispatch({ queueOutcome: "queued" });
expect(queue).toHaveBeenCalledTimes(1);
expect(direct).not.toHaveBeenCalled();

View File

@@ -115,6 +115,16 @@ describe("subagent registry persistence", () => {
return registryPath;
};
const readPersistedRun = async <T>(
registryPath: string,
runId: string,
): Promise<T | undefined> => {
const parsed = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
runs?: Record<string, unknown>;
};
return parsed.runs?.[runId] as T | undefined;
};
const createPersistedEndedRun = (params: {
runId: string;
childSessionKey: string;
@@ -316,11 +326,12 @@ describe("subagent registry persistence", () => {
await restartRegistryAndFlush();
expect(announceSpy).toHaveBeenCalledTimes(1);
const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
runs: Record<string, { cleanupHandled?: boolean; cleanupCompletedAt?: number }>;
};
expect(afterFirst.runs["run-3"].cleanupHandled).toBe(false);
expect(afterFirst.runs["run-3"].cleanupCompletedAt).toBeUndefined();
const afterFirst = await readPersistedRun<{
cleanupHandled?: boolean;
cleanupCompletedAt?: number;
}>(registryPath, "run-3");
expect(afterFirst?.cleanupHandled).toBe(false);
expect(afterFirst?.cleanupCompletedAt).toBeUndefined();
announceSpy.mockResolvedValueOnce(true);
await restartRegistryAndFlush();
@@ -345,10 +356,8 @@ describe("subagent registry persistence", () => {
await restartRegistryAndFlush();
expect(announceSpy).toHaveBeenCalledTimes(1);
const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
runs: Record<string, { cleanupHandled?: boolean }>;
};
expect(afterFirst.runs["run-4"]?.cleanupHandled).toBe(false);
const afterFirst = await readPersistedRun<{ cleanupHandled?: boolean }>(registryPath, "run-4");
expect(afterFirst?.cleanupHandled).toBe(false);
announceSpy.mockResolvedValueOnce(true);
await restartRegistryAndFlush();

View File

@@ -28,6 +28,27 @@ describe("cron tool", () => {
return params?.payload?.text ?? "";
}
function expectSingleGatewayCallMethod(method: string) {
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = readGatewayCall(0);
expect(call.method).toBe(method);
return call.params;
}
function buildReminderAgentTurnJob(overrides: Record<string, unknown> = {}): {
name: string;
schedule: { at: string };
payload: { kind: "agentTurn"; message: string };
delivery?: { mode: string; to?: string };
} {
return {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
...overrides,
};
}
async function executeAddAndReadDelivery(params: {
callId: string;
agentSessionKey: string;
@@ -37,9 +58,7 @@ describe("cron tool", () => {
await tool.execute(params.callId, {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
...buildReminderAgentTurnJob(),
...(params.delivery !== undefined ? { delivery: params.delivery } : {}),
},
});
@@ -114,13 +133,8 @@ describe("cron tool", () => {
const tool = createCronTool();
await tool.execute("call1", args);
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: unknown;
};
expect(call.method).toBe(`cron.${action}`);
expect(call.params).toEqual(expectedParams);
const params = expectSingleGatewayCallMethod(`cron.${action}`);
expect(params).toEqual(expectedParams);
});
it("prefers jobId over id when both are provided", async () => {
@@ -131,10 +145,7 @@ describe("cron tool", () => {
id: "job-legacy",
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: unknown;
};
expect(call?.params).toEqual({ id: "job-primary", mode: "force" });
expect(readGatewayCall().params).toEqual({ id: "job-primary", mode: "force" });
});
it("supports due-only run mode", async () => {
@@ -145,10 +156,7 @@ describe("cron tool", () => {
runMode: "due",
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: unknown;
};
expect(call?.params).toEqual({ id: "job-due", mode: "due" });
expect(readGatewayCall().params).toEqual({ id: "job-due", mode: "due" });
});
it("normalizes cron.add job payloads", async () => {
@@ -164,13 +172,8 @@ describe("cron tool", () => {
},
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: unknown;
};
expect(call.method).toBe("cron.add");
expect(call.params).toEqual({
const params = expectSingleGatewayCallMethod("cron.add");
expect(params).toEqual({
name: "wake-up",
enabled: true,
deleteAfterRun: true,
@@ -367,15 +370,12 @@ describe("cron tool", () => {
payload: { kind: "agentTurn", message: "do stuff" },
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: { name?: string; sessionTarget?: string; payload?: { kind?: string } };
};
expect(call.method).toBe("cron.add");
expect(call.params?.name).toBe("flat-job");
expect(call.params?.sessionTarget).toBe("isolated");
expect(call.params?.payload?.kind).toBe("agentTurn");
const params = expectSingleGatewayCallMethod("cron.add") as
| { name?: string; sessionTarget?: string; payload?: { kind?: string } }
| undefined;
expect(params?.name).toBe("flat-job");
expect(params?.sessionTarget).toBe("isolated");
expect(params?.payload?.kind).toBe("agentTurn");
});
it("recovers flat params when job is empty object", async () => {
@@ -391,15 +391,12 @@ describe("cron tool", () => {
payload: { kind: "systemEvent", text: "wake up" },
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: { name?: string; sessionTarget?: string; payload?: { text?: string } };
};
expect(call.method).toBe("cron.add");
expect(call.params?.name).toBe("empty-job");
expect(call.params?.sessionTarget).toBe("main");
expect(call.params?.payload?.text).toBe("wake up");
const params = expectSingleGatewayCallMethod("cron.add") as
| { name?: string; sessionTarget?: string; payload?: { text?: string } }
| undefined;
expect(params?.name).toBe("empty-job");
expect(params?.sessionTarget).toBe("main");
expect(params?.payload?.text).toBe("wake up");
});
it("recovers flat message shorthand as agentTurn payload", async () => {
@@ -412,16 +409,13 @@ describe("cron tool", () => {
message: "do stuff",
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: { payload?: { kind?: string; message?: string }; sessionTarget?: string };
};
expect(call.method).toBe("cron.add");
const params = expectSingleGatewayCallMethod("cron.add") as
| { payload?: { kind?: string; message?: string }; sessionTarget?: string }
| undefined;
// normalizeCronJobCreate infers agentTurn from message and isolated from agentTurn
expect(call.params?.payload?.kind).toBe("agentTurn");
expect(call.params?.payload?.message).toBe("do stuff");
expect(call.params?.sessionTarget).toBe("isolated");
expect(params?.payload?.kind).toBe("agentTurn");
expect(params?.payload?.message).toBe("do stuff");
expect(params?.sessionTarget).toBe("isolated");
});
it("does not recover flat params when no meaningful job field is present", async () => {
@@ -486,9 +480,7 @@ describe("cron tool", () => {
tool.execute("call-webhook-missing", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
...buildReminderAgentTurnJob(),
delivery: { mode: "webhook" },
},
}),
@@ -503,9 +495,7 @@ describe("cron tool", () => {
tool.execute("call-webhook-invalid", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
...buildReminderAgentTurnJob(),
delivery: { mode: "webhook", to: "ftp://example.invalid/cron-finished" },
},
}),
@@ -524,15 +514,12 @@ describe("cron tool", () => {
enabled: false,
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: { id?: string; patch?: { name?: string; enabled?: boolean } };
};
expect(call.method).toBe("cron.update");
expect(call.params?.id).toBe("job-1");
expect(call.params?.patch?.name).toBe("new-name");
expect(call.params?.patch?.enabled).toBe(false);
const params = expectSingleGatewayCallMethod("cron.update") as
| { id?: string; patch?: { name?: string; enabled?: boolean } }
| undefined;
expect(params?.id).toBe("job-1");
expect(params?.patch?.name).toBe("new-name");
expect(params?.patch?.enabled).toBe(false);
});
it("recovers additional flat patch params for update action", async () => {
@@ -546,16 +533,17 @@ describe("cron tool", () => {
failureAlert: { after: 3, cooldownMs: 60_000 },
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: {
id?: string;
patch?: { sessionTarget?: string; failureAlert?: { after?: number; cooldownMs?: number } };
};
};
expect(call.method).toBe("cron.update");
expect(call.params?.id).toBe("job-2");
expect(call.params?.patch?.sessionTarget).toBe("main");
expect(call.params?.patch?.failureAlert).toEqual({ after: 3, cooldownMs: 60_000 });
const params = expectSingleGatewayCallMethod("cron.update") as
| {
id?: string;
patch?: {
sessionTarget?: string;
failureAlert?: { after?: number; cooldownMs?: number };
};
}
| undefined;
expect(params?.id).toBe("job-2");
expect(params?.patch?.sessionTarget).toBe("main");
expect(params?.patch?.failureAlert).toEqual({ after: 3, cooldownMs: 60_000 });
});
});

View File

@@ -60,32 +60,38 @@ export function coercePdfAssistantText(params: {
provider: string;
model: string;
}): string {
const stop = params.message.stopReason;
const label = `${params.provider}/${params.model}`;
const errorMessage = params.message.errorMessage?.trim();
if (stop === "error" || stop === "aborted") {
const fail = (message?: string) => {
throw new Error(
errorMessage
? `PDF model failed (${params.provider}/${params.model}): ${errorMessage}`
: `PDF model failed (${params.provider}/${params.model})`,
message ? `PDF model failed (${label}): ${message}` : `PDF model failed (${label})`,
);
};
if (params.message.stopReason === "error" || params.message.stopReason === "aborted") {
fail(errorMessage);
}
if (errorMessage) {
throw new Error(`PDF model failed (${params.provider}/${params.model}): ${errorMessage}`);
fail(errorMessage);
}
const text = extractAssistantText(params.message);
if (text.trim()) {
return text.trim();
const trimmed = text.trim();
if (trimmed) {
return trimmed;
}
throw new Error(`PDF model returned no text (${params.provider}/${params.model}).`);
throw new Error(`PDF model returned no text (${label}).`);
}
export function coercePdfModelConfig(cfg?: OpenClawConfig): PdfModelConfig {
const primary = resolveAgentModelPrimaryValue(cfg?.agents?.defaults?.pdfModel);
const fallbacks = resolveAgentModelFallbackValues(cfg?.agents?.defaults?.pdfModel);
return {
...(primary?.trim() ? { primary: primary.trim() } : {}),
...(fallbacks.length > 0 ? { fallbacks } : {}),
};
const modelConfig: PdfModelConfig = {};
if (primary?.trim()) {
modelConfig.primary = primary.trim();
}
if (fallbacks.length > 0) {
modelConfig.fallbacks = fallbacks;
}
return modelConfig;
}
export function resolvePdfToolMaxTokens(

View File

@@ -89,9 +89,14 @@ export async function handleTelegramAction(
mediaLocalRoots?: readonly string[];
},
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId");
const isActionEnabled = createTelegramActionGate({ cfg, accountId });
const { action, accountId } = {
action: readStringParam(params, "action", { required: true }),
accountId: readStringParam(params, "accountId"),
};
const isActionEnabled = createTelegramActionGate({
cfg,
accountId,
});
if (action === "react") {
// All react failures return soft results (jsonResult with ok:false) instead

View File

@@ -1,6 +1,10 @@
import { completeSimple, getModel } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
import {
createSingleUserPromptMessage,
extractNonEmptyAssistantText,
} from "./live-test-helpers.js";
const ZAI_KEY = process.env.ZAI_API_KEY ?? process.env.Z_AI_API_KEY ?? "";
const LIVE = isTruthyEnvValue(process.env.ZAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
@@ -12,20 +16,11 @@ async function expectModelReturnsAssistantText(modelId: "glm-4.7" | "glm-4.7-fla
const res = await completeSimple(
model,
{
messages: [
{
role: "user",
content: "Reply with the word ok.",
timestamp: Date.now(),
},
],
messages: createSingleUserPromptMessage(),
},
{ apiKey: ZAI_KEY, maxTokens: 64 },
);
const text = res.content
.filter((block) => block.type === "text")
.map((block) => block.text.trim())
.join(" ");
const text = extractNonEmptyAssistantText(res.content);
expect(text.length).toBeGreaterThan(0);
}

View File

@@ -1,8 +1,10 @@
import { vi } from "vitest";
export const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
runEmbeddedPiAgent: (...args: unknown[]) => runEmbeddedPiAgentMock(...args),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),

View File

@@ -1,23 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { runEmbeddedPiAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js";
import { createTempHomeHarness, makeReplyConfig } from "./reply.test-harness.js";
const runEmbeddedPiAgentMock = vi.fn();
vi.mock(
"../agents/model-fallback.js",
async () => await import("../test-utils/model-fallback.mock.js"),
);
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
const webMocks = vi.hoisted(() => ({
webAuthExists: vi.fn().mockResolvedValue(true),
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),

View File

@@ -1,24 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { runEmbeddedPiAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js";
import { createTempHomeHarness, makeReplyConfig } from "./reply.test-harness.js";
const agentMocks = vi.hoisted(() => ({
runEmbeddedPiAgent: vi.fn(),
loadModelCatalog: vi.fn(),
webAuthExists: vi.fn().mockResolvedValue(true),
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
}));
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: agentMocks.runEmbeddedPiAgent,
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: agentMocks.loadModelCatalog,
}));
@@ -36,7 +27,7 @@ const { withTempHome } = createTempHomeHarness({ prefix: "openclaw-rawbody-" });
describe("RawBody directive parsing", () => {
beforeEach(() => {
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
agentMocks.runEmbeddedPiAgent.mockClear();
runEmbeddedPiAgentMock.mockClear();
agentMocks.loadModelCatalog.mockClear();
agentMocks.loadModelCatalog.mockResolvedValue([
{ id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" },
@@ -49,7 +40,7 @@ describe("RawBody directive parsing", () => {
it("handles directives and history in the prompt", async () => {
await withTempHome(async (home) => {
agentMocks.runEmbeddedPiAgent.mockResolvedValue({
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
@@ -79,10 +70,10 @@ describe("RawBody directive parsing", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(agentMocks.runEmbeddedPiAgent).toHaveBeenCalledOnce();
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
const prompt =
(agentMocks.runEmbeddedPiAgent.mock.calls[0]?.[0] as { prompt?: string } | undefined)
?.prompt ?? "";
(runEmbeddedPiAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined)?.prompt ??
"";
expect(prompt).toContain("Chat history since last reply (untrusted, for context):");
expect(prompt).toContain('"sender": "Peter"');
expect(prompt).toContain('"body": "hello"');

View File

@@ -84,8 +84,10 @@ vi.mock("../../acp/runtime/session-meta.js", () => ({
resolveSessionStorePathForAcp: (args: unknown) => hoisted.resolveSessionStorePathForAcpMock(args),
}));
vi.mock("../../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../config/sessions.js")>();
vi.mock("../../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
"../../config/sessions.js",
);
return {
...actual,
loadSessionStore: (...args: unknown[]) => hoisted.loadSessionStoreMock(...args),

View File

@@ -2,18 +2,76 @@ import type { BrowserActionOk, BrowserActionTargetOk } from "./client-actions-ty
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
import { fetchBrowserJson } from "./client-fetch.js";
type TargetedProfileOptions = {
targetId?: string;
profile?: string;
};
type HttpCredentialsOptions = TargetedProfileOptions & {
username?: string;
password?: string;
clear?: boolean;
};
type GeolocationOptions = TargetedProfileOptions & {
latitude?: number;
longitude?: number;
accuracy?: number;
origin?: string;
clear?: boolean;
};
function buildStateQuery(params: { targetId?: string; key?: string; profile?: string }): string {
const query = new URLSearchParams();
if (params.targetId) {
query.set("targetId", params.targetId);
}
if (params.key) {
query.set("key", params.key);
}
if (params.profile) {
query.set("profile", params.profile);
}
const suffix = query.toString();
return suffix ? `?${suffix}` : "";
}
async function postProfileJson<T>(
baseUrl: string | undefined,
params: { path: string; profile?: string; body: unknown },
): Promise<T> {
const query = buildProfileQuery(params.profile);
return await fetchBrowserJson<T>(withBaseUrl(baseUrl, `${params.path}${query}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params.body),
timeoutMs: 20000,
});
}
async function postTargetedProfileJson(
baseUrl: string | undefined,
params: {
path: string;
opts: { targetId?: string; profile?: string };
body: Record<string, unknown>;
},
): Promise<BrowserActionTargetOk> {
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: params.path,
profile: params.opts.profile,
body: {
targetId: params.opts.targetId,
...params.body,
},
});
}
export async function browserCookies(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
const q = new URLSearchParams();
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (opts.profile) {
q.set("profile", opts.profile);
}
const suffix = q.toString() ? `?${q.toString()}` : "";
const suffix = buildStateQuery({ targetId: opts.targetId, profile: opts.profile });
return await fetchBrowserJson<{
ok: true;
targetId: string;
@@ -29,12 +87,10 @@ export async function browserCookiesSet(
profile?: string;
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/set${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/cookies/set",
profile: opts.profile,
body: { targetId: opts.targetId, cookie: opts.cookie },
});
}
@@ -42,12 +98,10 @@ export async function browserCookiesClear(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/clear${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/cookies/clear",
profile: opts.profile,
body: { targetId: opts.targetId },
});
}
@@ -60,17 +114,7 @@ export async function browserStorageGet(
profile?: string;
},
): Promise<{ ok: true; targetId: string; values: Record<string, string> }> {
const q = new URLSearchParams();
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (opts.key) {
q.set("key", opts.key);
}
if (opts.profile) {
q.set("profile", opts.profile);
}
const suffix = q.toString() ? `?${q.toString()}` : "";
const suffix = buildStateQuery({ targetId: opts.targetId, key: opts.key, profile: opts.profile });
return await fetchBrowserJson<{
ok: true;
targetId: string;
@@ -88,48 +132,36 @@ export async function browserStorageSet(
profile?: string;
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/storage/${opts.kind}/set${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
key: opts.key,
value: opts.value,
}),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: `/storage/${opts.kind}/set`,
profile: opts.profile,
body: {
targetId: opts.targetId,
key: opts.key,
value: opts.value,
},
);
});
}
export async function browserStorageClear(
baseUrl: string | undefined,
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/storage/${opts.kind}/clear${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
timeoutMs: 20000,
},
);
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: `/storage/${opts.kind}/clear`,
profile: opts.profile,
body: { targetId: opts.targetId },
});
}
export async function browserSetOffline(
baseUrl: string | undefined,
opts: { offline: boolean; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/offline${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/offline",
profile: opts.profile,
body: { targetId: opts.targetId, offline: opts.offline },
});
}
@@ -141,71 +173,43 @@ export async function browserSetHeaders(
profile?: string;
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/headers${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/headers",
profile: opts.profile,
body: { targetId: opts.targetId, headers: opts.headers },
});
}
export async function browserSetHttpCredentials(
baseUrl: string | undefined,
opts: {
username?: string;
password?: string;
clear?: boolean;
targetId?: string;
profile?: string;
} = {},
opts: HttpCredentialsOptions = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/set/credentials${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
username: opts.username,
password: opts.password,
clear: opts.clear,
}),
timeoutMs: 20000,
return await postTargetedProfileJson(baseUrl, {
path: "/set/credentials",
opts,
body: {
username: opts.username,
password: opts.password,
clear: opts.clear,
},
);
});
}
export async function browserSetGeolocation(
baseUrl: string | undefined,
opts: {
latitude?: number;
longitude?: number;
accuracy?: number;
origin?: string;
clear?: boolean;
targetId?: string;
profile?: string;
} = {},
opts: GeolocationOptions = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/set/geolocation${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
latitude: opts.latitude,
longitude: opts.longitude,
accuracy: opts.accuracy,
origin: opts.origin,
clear: opts.clear,
}),
timeoutMs: 20000,
return await postTargetedProfileJson(baseUrl, {
path: "/set/geolocation",
opts,
body: {
latitude: opts.latitude,
longitude: opts.longitude,
accuracy: opts.accuracy,
origin: opts.origin,
clear: opts.clear,
},
);
});
}
export async function browserSetMedia(
@@ -216,15 +220,13 @@ export async function browserSetMedia(
profile?: string;
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/media${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/media",
profile: opts.profile,
body: {
targetId: opts.targetId,
colorScheme: opts.colorScheme,
}),
timeoutMs: 20000,
},
});
}
@@ -232,15 +234,13 @@ export async function browserSetTimezone(
baseUrl: string | undefined,
opts: { timezoneId: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/timezone${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/timezone",
profile: opts.profile,
body: {
targetId: opts.targetId,
timezoneId: opts.timezoneId,
}),
timeoutMs: 20000,
},
});
}
@@ -248,12 +248,10 @@ export async function browserSetLocale(
baseUrl: string | undefined,
opts: { locale: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/locale${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/locale",
profile: opts.profile,
body: { targetId: opts.targetId, locale: opts.locale },
});
}
@@ -261,12 +259,10 @@ export async function browserSetDevice(
baseUrl: string | undefined,
opts: { name: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/device${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/device",
profile: opts.profile,
body: { targetId: opts.targetId, name: opts.name },
});
}
@@ -274,11 +270,9 @@ export async function browserClearPermissions(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/set/geolocation${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionOk>(baseUrl, {
path: "/set/geolocation",
profile: opts.profile,
body: { targetId: opts.targetId, clear: true },
});
}

View File

@@ -28,6 +28,17 @@ async function withFixtureRoot<T>(
}
}
async function createAliasedUploadsRoot(baseDir: string): Promise<{
canonicalUploadsDir: string;
aliasedUploadsDir: string;
}> {
const canonicalUploadsDir = path.join(baseDir, "canonical", "uploads");
const aliasedUploadsDir = path.join(baseDir, "uploads-link");
await fs.mkdir(canonicalUploadsDir, { recursive: true });
await fs.symlink(canonicalUploadsDir, aliasedUploadsDir);
return { canonicalUploadsDir, aliasedUploadsDir };
}
describe("resolveExistingPathsWithinRoot", () => {
function expectInvalidResult(
result: Awaited<ReturnType<typeof resolveExistingPathsWithinRoot>>,
@@ -167,10 +178,7 @@ describe("resolveExistingPathsWithinRoot", () => {
"accepts canonical absolute paths when upload root is a symlink alias",
async () => {
await withFixtureRoot(async ({ baseDir }) => {
const canonicalUploadsDir = path.join(baseDir, "canonical", "uploads");
const aliasedUploadsDir = path.join(baseDir, "uploads-link");
await fs.mkdir(canonicalUploadsDir, { recursive: true });
await fs.symlink(canonicalUploadsDir, aliasedUploadsDir);
const { canonicalUploadsDir, aliasedUploadsDir } = await createAliasedUploadsRoot(baseDir);
const filePath = path.join(canonicalUploadsDir, "ok.txt");
await fs.writeFile(filePath, "ok", "utf8");
@@ -198,10 +206,7 @@ describe("resolveExistingPathsWithinRoot", () => {
"rejects canonical absolute paths outside symlinked upload root",
async () => {
await withFixtureRoot(async ({ baseDir }) => {
const canonicalUploadsDir = path.join(baseDir, "canonical", "uploads");
const aliasedUploadsDir = path.join(baseDir, "uploads-link");
await fs.mkdir(canonicalUploadsDir, { recursive: true });
await fs.symlink(canonicalUploadsDir, aliasedUploadsDir);
const { aliasedUploadsDir } = await createAliasedUploadsRoot(baseDir);
const outsideDir = path.join(baseDir, "outside");
await fs.mkdir(outsideDir, { recursive: true });

View File

@@ -45,15 +45,23 @@ function createCtx(resolved: BrowserServerState["resolved"]) {
return { state, ctx };
}
async function createWorkProfileWithConfig(params: {
resolved: BrowserServerState["resolved"];
browserConfig: Record<string, unknown>;
}) {
const { ctx, state } = createCtx(params.resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: params.browserConfig });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({ name: "work" });
return { result, state };
}
describe("BrowserProfilesService", () => {
it("allocates next local port for new profiles", async () => {
const resolved = resolveBrowserConfig({});
const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({ name: "work" });
const { result, state } = await createWorkProfileWithConfig({
resolved: resolveBrowserConfig({}),
browserConfig: { profiles: {} },
});
expect(result.cdpPort).toBe(18801);
expect(result.isRemote).toBe(false);
@@ -74,12 +82,10 @@ describe("BrowserProfilesService", () => {
...baseWithoutRange,
controlPort: 30000,
} as BrowserServerState["resolved"];
const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({ name: "work" });
const { result, state } = await createWorkProfileWithConfig({
resolved,
browserConfig: { profiles: {} },
});
expect(result.cdpPort).toBe(30009);
expect(state.resolved.profiles.work?.cdpPort).toBe(30009);
@@ -87,13 +93,10 @@ describe("BrowserProfilesService", () => {
});
it("allocates from configured cdpPortRangeStart for new local profiles", async () => {
const resolved = resolveBrowserConfig({ cdpPortRangeStart: 19000 });
const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { cdpPortRangeStart: 19000, profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({ name: "work" });
const { result, state } = await createWorkProfileWithConfig({
resolved: resolveBrowserConfig({ cdpPortRangeStart: 19000 }),
browserConfig: { cdpPortRangeStart: 19000, profiles: {} },
});
expect(result.cdpPort).toBe(19001);
expect(result.isRemote).toBe(false);

View File

@@ -456,6 +456,18 @@ async function findPageByTargetId(
return null;
}
async function resolvePageByTargetIdOrThrow(opts: {
cdpUrl: string;
targetId: string;
}): Promise<Page> {
const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
}
return page;
}
export async function getPageForTargetId(opts: {
cdpUrl: string;
targetId?: string;
@@ -782,11 +794,7 @@ export async function closePageByTargetIdViaPlaywright(opts: {
cdpUrl: string;
targetId: string;
}): Promise<void> {
const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
}
const page = await resolvePageByTargetIdOrThrow(opts);
await page.close();
}
@@ -798,11 +806,7 @@ export async function focusPageByTargetIdViaPlaywright(opts: {
cdpUrl: string;
targetId: string;
}): Promise<void> {
const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
}
const page = await resolvePageByTargetIdOrThrow(opts);
try {
await page.bringToFront();
} catch (err) {

View File

@@ -41,6 +41,18 @@ vi.mock("./paths.js", () => {
let setInputFilesViaPlaywright: typeof import("./pw-tools-core.interactions.js").setInputFilesViaPlaywright;
function seedSingleLocatorPage(): { setInputFiles: ReturnType<typeof vi.fn> } {
const setInputFiles = vi.fn(async () => {});
locator = {
setInputFiles,
elementHandle: vi.fn(async () => null),
};
page = {
locator: vi.fn(() => ({ first: () => locator })),
};
return { setInputFiles };
}
describe("setInputFilesViaPlaywright", () => {
beforeAll(async () => {
({ setInputFilesViaPlaywright } = await import("./pw-tools-core.interactions.js"));
@@ -57,14 +69,7 @@ describe("setInputFilesViaPlaywright", () => {
});
it("revalidates upload paths and uses resolved canonical paths for inputRef", async () => {
const setInputFiles = vi.fn(async () => {});
locator = {
setInputFiles,
elementHandle: vi.fn(async () => null),
};
page = {
locator: vi.fn(() => ({ first: () => locator })),
};
const { setInputFiles } = seedSingleLocatorPage();
await setInputFilesViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
@@ -88,14 +93,7 @@ describe("setInputFilesViaPlaywright", () => {
error: "Invalid path: must stay within uploads directory",
});
const setInputFiles = vi.fn(async () => {});
locator = {
setInputFiles,
elementHandle: vi.fn(async () => null),
};
page = {
locator: vi.fn(() => ({ first: () => locator })),
};
const { setInputFiles } = seedSingleLocatorPage();
await expect(
setInputFilesViaPlaywright({

View File

@@ -14,6 +14,17 @@ installPwToolsCoreTestHooks();
const sessionMocks = getPwToolsCoreSessionMocks();
const mod = await import("./pw-tools-core.js");
function createFileChooserPageMocks() {
const fileChooser = { setFiles: vi.fn(async () => {}) };
const press = vi.fn(async () => {});
const waitForEvent = vi.fn(async () => fileChooser);
setPwToolsCoreCurrentPage({
waitForEvent,
keyboard: { press },
});
return { fileChooser, press, waitForEvent };
}
describe("pw-tools-core", () => {
it("screenshots an element selector", async () => {
const elementScreenshot = vi.fn(async () => Buffer.from("E"));
@@ -118,13 +129,7 @@ describe("pw-tools-core", () => {
});
it("revalidates file-chooser paths at use-time and cancels missing files", async () => {
const missingPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-missing-${crypto.randomUUID()}.txt`);
const fileChooser = { setFiles: vi.fn(async () => {}) };
const press = vi.fn(async () => {});
const waitForEvent = vi.fn(async () => fileChooser);
setPwToolsCoreCurrentPage({
waitForEvent,
keyboard: { press },
});
const { fileChooser, press } = createFileChooserPageMocks();
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
@@ -139,13 +144,7 @@ describe("pw-tools-core", () => {
expect(fileChooser.setFiles).not.toHaveBeenCalled();
});
it("arms the next file chooser and escapes if no paths provided", async () => {
const fileChooser = { setFiles: vi.fn(async () => {}) };
const press = vi.fn(async () => {});
const waitForEvent = vi.fn(async () => fileChooser);
setPwToolsCoreCurrentPage({
waitForEvent,
keyboard: { press },
});
const { fileChooser, press } = createFileChooserPageMocks();
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",

View File

@@ -1,17 +1,7 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => true),
isChromeReachable: vi.fn(async () => true),
launchOpenClawChrome: vi.fn(async () => {
throw new Error("unexpected launch");
}),
resolveOpenClawUserDataDir: vi.fn(() => "/tmp/openclaw-test"),
stopOpenClawChrome: vi.fn(async () => {}),
}));
import "./server-context.chrome-test-harness.js";
import * as chromeModule from "./chrome.js";
import type { RunningChrome } from "./chrome.js";
import type { BrowserServerState } from "./server-context.js";
@@ -63,6 +53,22 @@ function mockLaunchedChrome(
});
}
function setupEnsureBrowserAvailableHarness() {
vi.useFakeTimers();
const launchOpenClawChrome = vi.mocked(chromeModule.launchOpenClawChrome);
const stopOpenClawChrome = vi.mocked(chromeModule.stopOpenClawChrome);
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady);
isChromeReachable.mockResolvedValue(false);
const state = makeBrowserState();
const ctx = createBrowserRouteContext({ getState: () => state });
const profile = ctx.forProfile("openclaw");
return { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile };
}
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
@@ -71,21 +77,11 @@ afterEach(() => {
describe("browser server-context ensureBrowserAvailable", () => {
it("waits for CDP readiness after launching to avoid follow-up PortInUseError races (#21149)", async () => {
vi.useFakeTimers();
const launchOpenClawChrome = vi.mocked(chromeModule.launchOpenClawChrome);
const stopOpenClawChrome = vi.mocked(chromeModule.stopOpenClawChrome);
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady);
isChromeReachable.mockResolvedValue(false);
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } =
setupEnsureBrowserAvailableHarness();
isChromeCdpReady.mockResolvedValueOnce(false).mockResolvedValue(true);
mockLaunchedChrome(launchOpenClawChrome, 123);
const state = makeBrowserState();
const ctx = createBrowserRouteContext({ getState: () => state });
const profile = ctx.forProfile("openclaw");
const promise = profile.ensureBrowserAvailable();
await vi.advanceTimersByTimeAsync(100);
await expect(promise).resolves.toBeUndefined();
@@ -96,21 +92,11 @@ describe("browser server-context ensureBrowserAvailable", () => {
});
it("stops launched chrome when CDP readiness never arrives", async () => {
vi.useFakeTimers();
const launchOpenClawChrome = vi.mocked(chromeModule.launchOpenClawChrome);
const stopOpenClawChrome = vi.mocked(chromeModule.stopOpenClawChrome);
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady);
isChromeReachable.mockResolvedValue(false);
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } =
setupEnsureBrowserAvailableHarness();
isChromeCdpReady.mockResolvedValue(false);
mockLaunchedChrome(launchOpenClawChrome, 321);
const state = makeBrowserState();
const ctx = createBrowserRouteContext({ getState: () => state });
const profile = ctx.forProfile("openclaw");
const promise = profile.ensureBrowserAvailable();
const rejected = expect(promise).rejects.toThrow("not reachable after start");
await vi.advanceTimersByTimeAsync(8100);

View File

@@ -24,23 +24,41 @@ afterEach(() => {
vi.clearAllMocks();
});
function localOpenClawProfile(): Parameters<typeof createProfileResetOps>[0]["profile"] {
return {
name: "openclaw",
cdpUrl: "http://127.0.0.1:18800",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
cdpPort: 18800,
color: "#f60",
driver: "openclaw",
attachOnly: false,
};
}
function createLocalOpenClawResetOps(
params: Omit<Parameters<typeof createProfileResetOps>[0], "profile">,
) {
return createProfileResetOps({ profile: localOpenClawProfile(), ...params });
}
function createStatelessResetOps(profile: Parameters<typeof createProfileResetOps>[0]["profile"]) {
return createProfileResetOps({
profile,
getProfileState: () => ({ profile: {} as never, running: null }),
stopRunningBrowser: vi.fn(async () => ({ stopped: false })),
isHttpReachable: vi.fn(async () => false),
resolveOpenClawUserDataDir: (name: string) => `/tmp/${name}`,
});
}
describe("createProfileResetOps", () => {
it("stops extension relay for extension profiles", async () => {
const ops = createProfileResetOps({
profile: {
name: "chrome",
cdpUrl: "http://127.0.0.1:18800",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
cdpPort: 18800,
color: "#f60",
driver: "extension",
attachOnly: false,
},
getProfileState: () => ({ profile: {} as never, running: null }),
stopRunningBrowser: vi.fn(async () => ({ stopped: false })),
isHttpReachable: vi.fn(async () => false),
resolveOpenClawUserDataDir: (name: string) => `/tmp/${name}`,
const ops = createStatelessResetOps({
...localOpenClawProfile(),
name: "chrome",
driver: "extension",
});
await expect(ops.resetProfile()).resolves.toEqual({
@@ -54,21 +72,14 @@ describe("createProfileResetOps", () => {
});
it("rejects remote non-extension profiles", async () => {
const ops = createProfileResetOps({
profile: {
name: "remote",
cdpUrl: "https://browserless.example/chrome",
cdpHost: "browserless.example",
cdpIsLoopback: false,
cdpPort: 443,
color: "#0f0",
driver: "openclaw",
attachOnly: false,
},
getProfileState: () => ({ profile: {} as never, running: null }),
stopRunningBrowser: vi.fn(async () => ({ stopped: false })),
isHttpReachable: vi.fn(async () => false),
resolveOpenClawUserDataDir: (name: string) => `/tmp/${name}`,
const ops = createStatelessResetOps({
...localOpenClawProfile(),
name: "remote",
cdpUrl: "https://browserless.example/chrome",
cdpHost: "browserless.example",
cdpIsLoopback: false,
cdpPort: 443,
color: "#0f0",
});
await expect(ops.resetProfile()).rejects.toThrow(/only supported for local profiles/i);
@@ -86,17 +97,7 @@ describe("createProfileResetOps", () => {
running: { pid: 1 } as never,
}));
const ops = createProfileResetOps({
profile: {
name: "openclaw",
cdpUrl: "http://127.0.0.1:18800",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
cdpPort: 18800,
color: "#f60",
driver: "openclaw",
attachOnly: false,
},
const ops = createLocalOpenClawResetOps({
getProfileState,
stopRunningBrowser,
isHttpReachable,
@@ -121,17 +122,7 @@ describe("createProfileResetOps", () => {
fs.mkdirSync(profileDir, { recursive: true });
const stopRunningBrowser = vi.fn(async () => ({ stopped: false }));
const ops = createProfileResetOps({
profile: {
name: "openclaw",
cdpUrl: "http://127.0.0.1:18800",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
cdpPort: 18800,
color: "#f60",
driver: "openclaw",
attachOnly: false,
},
const ops = createLocalOpenClawResetOps({
getProfileState: () => ({ profile: {} as never, running: null }),
stopRunningBrowser,
isHttpReachable: vi.fn(async () => true),

View File

@@ -54,6 +54,34 @@ function createOldTabCleanupFetchMock(
});
}
function createManagedTabListFetchMock(params: {
existingTabs: ReturnType<typeof makeManagedTabsWithNew>;
onClose: (url: string) => Response | Promise<Response>;
}): ReturnType<typeof vi.fn> {
return vi.fn(async (url: unknown) => {
const value = String(url);
if (value.includes("/json/list")) {
return { ok: true, json: async () => params.existingTabs } as unknown as Response;
}
if (value.includes("/json/close/")) {
return await params.onClose(value);
}
throw new Error(`unexpected fetch: ${value}`);
});
}
async function openManagedTabWithRunningProfile(params: {
fetchMock: ReturnType<typeof vi.fn>;
url?: string;
}) {
global.fetch = withFetchPreconnect(params.fetchMock);
const state = makeState("openclaw");
seedRunningProfileState(state);
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
return await openclaw.openTab(params.url ?? "http://127.0.0.1:3009");
}
describe("browser server-context tab selection state", () => {
it("updates lastTargetId when openTab is created via CDP", async () => {
const createTargetViaCdp = vi
@@ -99,13 +127,7 @@ describe("browser server-context tab selection state", () => {
const existingTabs = makeManagedTabsWithNew();
const fetchMock = createOldTabCleanupFetchMock(existingTabs);
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
seedRunningProfileState(state);
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
const opened = await openclaw.openTab("http://127.0.0.1:3009");
const opened = await openManagedTabWithRunningProfile({ fetchMock });
expect(opened.targetId).toBe("NEW");
await expectOldManagedTabClose(fetchMock);
});
@@ -115,13 +137,7 @@ describe("browser server-context tab selection state", () => {
const existingTabs = makeManagedTabsWithNew({ newFirst: true });
const fetchMock = createOldTabCleanupFetchMock(existingTabs, { rejectNewTabClose: true });
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
seedRunningProfileState(state);
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
const opened = await openclaw.openTab("http://127.0.0.1:3009");
const opened = await openManagedTabWithRunningProfile({ fetchMock });
expect(opened.targetId).toBe("NEW");
await expectOldManagedTabClose(fetchMock);
expect(fetchMock).not.toHaveBeenCalledWith(
@@ -170,16 +186,11 @@ describe("browser server-context tab selection state", () => {
it("does not run managed tab cleanup in attachOnly mode", async () => {
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
const existingTabs = makeManagedTabsWithNew();
const fetchMock = vi.fn(async (url: unknown) => {
const value = String(url);
if (value.includes("/json/list")) {
return { ok: true, json: async () => existingTabs } as unknown as Response;
}
if (value.includes("/json/close/")) {
const fetchMock = createManagedTabListFetchMock({
existingTabs,
onClose: () => {
throw new Error("should not close tabs in attachOnly mode");
}
throw new Error(`unexpected fetch: ${value}`);
},
});
global.fetch = withFetchPreconnect(fetchMock);
@@ -199,26 +210,18 @@ describe("browser server-context tab selection state", () => {
it("does not block openTab on slow best-effort cleanup closes", async () => {
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
const existingTabs = makeManagedTabsWithNew();
const fetchMock = vi.fn(async (url: unknown) => {
const value = String(url);
if (value.includes("/json/list")) {
return { ok: true, json: async () => existingTabs } as unknown as Response;
}
if (value.includes("/json/close/OLD1")) {
return new Promise<Response>(() => {});
}
throw new Error(`unexpected fetch: ${value}`);
const fetchMock = createManagedTabListFetchMock({
existingTabs,
onClose: (url) => {
if (url.includes("/json/close/OLD1")) {
return new Promise<Response>(() => {});
}
throw new Error(`unexpected fetch: ${url}`);
},
});
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
seedRunningProfileState(state);
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
const opened = await Promise.race([
openclaw.openTab("http://127.0.0.1:3009"),
openManagedTabWithRunningProfile({ fetchMock }),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("openTab timed out waiting for cleanup")), 300),
),

View File

@@ -5,6 +5,15 @@ import { shortenHomePath } from "../utils.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
const BROWSER_DEBUG_TIMEOUT_MS = 20000;
type BrowserRequestParams = Parameters<typeof callBrowserRequest>[1];
type DebugContext = {
parent: BrowserParentOpts;
profile?: string;
};
function runBrowserDebug(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
defaultRuntime.error(danger(String(err)));
@@ -12,6 +21,39 @@ function runBrowserDebug(action: () => Promise<void>) {
});
}
async function withDebugContext(
cmd: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
action: (context: DebugContext) => Promise<void>,
) {
const parent = parentOpts(cmd);
await runBrowserDebug(() =>
action({
parent,
profile: parent.browserProfile,
}),
);
}
function printJsonResult(parent: BrowserParentOpts, result: unknown): boolean {
if (!parent.json) {
return false;
}
defaultRuntime.log(JSON.stringify(result, null, 2));
return true;
}
async function callDebugRequest<T>(
parent: BrowserParentOpts,
params: BrowserRequestParams,
): Promise<T> {
return callBrowserRequest<T>(parent, params, { timeoutMs: BROWSER_DEBUG_TIMEOUT_MS });
}
function resolveProfileQuery(profile?: string) {
return profile ? { profile } : undefined;
}
function resolveDebugQuery(params: {
targetId?: unknown;
clear?: unknown;
@@ -36,24 +78,17 @@ export function registerBrowserDebugCommands(
.argument("<ref>", "Ref id from snapshot")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (ref: string, opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserDebug(async () => {
const result = await callBrowserRequest(
parent,
{
method: "POST",
path: "/highlight",
query: profile ? { profile } : undefined,
body: {
ref: ref.trim(),
targetId: opts.targetId?.trim() || undefined,
},
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest(parent, {
method: "POST",
path: "/highlight",
query: resolveProfileQuery(profile),
body: {
ref: ref.trim(),
targetId: opts.targetId?.trim() || undefined,
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
});
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log(`highlighted ${ref.trim()}`);
@@ -66,26 +101,19 @@ export function registerBrowserDebugCommands(
.option("--clear", "Clear stored errors after reading", false)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserDebug(async () => {
const result = await callBrowserRequest<{
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest<{
errors: Array<{ timestamp: string; name?: string; message: string }>;
}>(
parent,
{
method: "GET",
path: "/errors",
query: resolveDebugQuery({
targetId: opts.targetId,
clear: opts.clear,
profile,
}),
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
}>(parent, {
method: "GET",
path: "/errors",
query: resolveDebugQuery({
targetId: opts.targetId,
clear: opts.clear,
profile,
}),
});
if (printJsonResult(parent, result)) {
return;
}
if (!result.errors.length) {
@@ -107,10 +135,8 @@ export function registerBrowserDebugCommands(
.option("--clear", "Clear stored requests after reading", false)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserDebug(async () => {
const result = await callBrowserRequest<{
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest<{
requests: Array<{
timestamp: string;
method: string;
@@ -119,22 +145,17 @@ export function registerBrowserDebugCommands(
url: string;
failureText?: string;
}>;
}>(
parent,
{
method: "GET",
path: "/requests",
query: resolveDebugQuery({
targetId: opts.targetId,
filter: opts.filter,
clear: opts.clear,
profile,
}),
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
}>(parent, {
method: "GET",
path: "/requests",
query: resolveDebugQuery({
targetId: opts.targetId,
filter: opts.filter,
clear: opts.clear,
profile,
}),
});
if (printJsonResult(parent, result)) {
return;
}
if (!result.requests.length) {
@@ -164,26 +185,19 @@ export function registerBrowserDebugCommands(
.option("--no-snapshots", "Disable snapshots")
.option("--sources", "Include sources (bigger traces)", false)
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserDebug(async () => {
const result = await callBrowserRequest(
parent,
{
method: "POST",
path: "/trace/start",
query: profile ? { profile } : undefined,
body: {
targetId: opts.targetId?.trim() || undefined,
screenshots: Boolean(opts.screenshots),
snapshots: Boolean(opts.snapshots),
sources: Boolean(opts.sources),
},
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest(parent, {
method: "POST",
path: "/trace/start",
query: resolveProfileQuery(profile),
body: {
targetId: opts.targetId?.trim() || undefined,
screenshots: Boolean(opts.screenshots),
snapshots: Boolean(opts.snapshots),
sources: Boolean(opts.sources),
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
});
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log("trace started");
@@ -199,24 +213,17 @@ export function registerBrowserDebugCommands(
)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserDebug(async () => {
const result = await callBrowserRequest<{ path: string }>(
parent,
{
method: "POST",
path: "/trace/stop",
query: profile ? { profile } : undefined,
body: {
targetId: opts.targetId?.trim() || undefined,
path: opts.out?.trim() || undefined,
},
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest<{ path: string }>(parent, {
method: "POST",
path: "/trace/stop",
query: resolveProfileQuery(profile),
body: {
targetId: opts.targetId?.trim() || undefined,
path: opts.out?.trim() || undefined,
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
});
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log(`TRACE:${shortenHomePath(result.path)}`);

View File

@@ -2,28 +2,36 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
import { createBrowserProgram } from "./browser-cli-test-helpers.js";
const mocks = vi.hoisted(() => ({
callBrowserRequest: vi.fn(async (_opts: unknown, req: { path?: string }) =>
req.path === "/"
? {
enabled: true,
running: true,
pid: 1,
cdpPort: 18800,
chosenBrowser: "chrome",
userDataDir: "/tmp/openclaw",
color: "blue",
headless: true,
attachOnly: false,
}
: {},
),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
}));
const mocks = vi.hoisted(() => {
const runtimeLog = vi.fn();
const runtimeError = vi.fn();
const runtimeExit = vi.fn();
return {
callBrowserRequest: vi.fn(async (_opts: unknown, req: { path?: string }) =>
req.path === "/"
? {
enabled: true,
running: true,
pid: 1,
cdpPort: 18800,
chosenBrowser: "chrome",
userDataDir: "/tmp/openclaw",
color: "blue",
headless: true,
attachOnly: false,
}
: {},
),
runtimeLog,
runtimeError,
runtimeExit,
runtime: {
log: runtimeLog,
error: runtimeError,
exit: runtimeExit,
},
};
});
vi.mock("./browser-cli-shared.js", () => ({
callBrowserRequest: mocks.callBrowserRequest,
@@ -51,9 +59,9 @@ describe("browser manage start timeout option", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
mocks.runtime.log.mockClear();
mocks.runtime.error.mockClear();
mocks.runtime.exit.mockClear();
mocks.runtimeLog.mockClear();
mocks.runtimeError.mockClear();
mocks.runtimeExit.mockClear();
});
it("uses parent --timeout for browser start instead of hardcoded 15s", async () => {

View File

@@ -13,6 +13,35 @@ import { shortenHomePath } from "../utils.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
function resolveProfileQuery(profile?: string) {
return profile ? { profile } : undefined;
}
function printJsonResult(parent: BrowserParentOpts, payload: unknown): boolean {
if (!parent?.json) {
return false;
}
defaultRuntime.log(JSON.stringify(payload, null, 2));
return true;
}
async function callTabAction(
parent: BrowserParentOpts,
profile: string | undefined,
body: { action: "new" | "select" | "close"; index?: number },
) {
return callBrowserRequest(
parent,
{
method: "POST",
path: "/tabs/action",
query: resolveProfileQuery(profile),
body,
},
{ timeoutMs: 10_000 },
);
}
async function fetchBrowserStatus(
parent: BrowserParentOpts,
profile?: string,
@@ -22,7 +51,7 @@ async function fetchBrowserStatus(
{
method: "GET",
path: "/",
query: profile ? { profile } : undefined,
query: resolveProfileQuery(profile),
},
{
timeoutMs: 1500,
@@ -37,11 +66,10 @@ async function runBrowserToggle(
await callBrowserRequest(parent, {
method: "POST",
path: params.path,
query: params.profile ? { profile: params.profile } : undefined,
query: resolveProfileQuery(params.profile),
});
const status = await fetchBrowserStatus(parent, params.profile);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
if (printJsonResult(parent, status)) {
return;
}
const name = status.profile ?? "openclaw";
@@ -82,8 +110,7 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
await runBrowserCommand(async () => {
const status = await fetchBrowserStatus(parent, parent?.browserProfile);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
if (printJsonResult(parent, status)) {
return;
}
const detectedPath = status.detectedExecutablePath ?? status.executablePath;
@@ -139,12 +166,11 @@ export function registerBrowserManageCommands(
{
method: "POST",
path: "/reset-profile",
query: profile ? { profile } : undefined,
query: resolveProfileQuery(profile),
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
if (printJsonResult(parent, result)) {
return;
}
if (!result.moved) {
@@ -168,7 +194,7 @@ export function registerBrowserManageCommands(
{
method: "GET",
path: "/tabs",
query: profile ? { profile } : undefined,
query: resolveProfileQuery(profile),
},
{ timeoutMs: 3000 },
);
@@ -189,7 +215,7 @@ export function registerBrowserManageCommands(
{
method: "POST",
path: "/tabs/action",
query: profile ? { profile } : undefined,
query: resolveProfileQuery(profile),
body: {
action: "list",
},
@@ -208,18 +234,8 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const result = await callBrowserRequest(
parent,
{
method: "POST",
path: "/tabs/action",
query: profile ? { profile } : undefined,
body: { action: "new" },
},
{ timeoutMs: 10_000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
const result = await callTabAction(parent, profile, { action: "new" });
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log("opened new tab");
@@ -239,18 +255,11 @@ export function registerBrowserManageCommands(
return;
}
await runBrowserCommand(async () => {
const result = await callBrowserRequest(
parent,
{
method: "POST",
path: "/tabs/action",
query: profile ? { profile } : undefined,
body: { action: "select", index: Math.floor(index) - 1 },
},
{ timeoutMs: 10_000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
const result = await callTabAction(parent, profile, {
action: "select",
index: Math.floor(index) - 1,
});
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log(`selected tab ${Math.floor(index)}`);
@@ -272,18 +281,8 @@ export function registerBrowserManageCommands(
return;
}
await runBrowserCommand(async () => {
const result = await callBrowserRequest(
parent,
{
method: "POST",
path: "/tabs/action",
query: profile ? { profile } : undefined,
body: { action: "close", index: idx },
},
{ timeoutMs: 10_000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
const result = await callTabAction(parent, profile, { action: "close", index: idx });
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log("closed tab");
@@ -303,13 +302,12 @@ export function registerBrowserManageCommands(
{
method: "POST",
path: "/tabs/open",
query: profile ? { profile } : undefined,
query: resolveProfileQuery(profile),
body: { url },
},
{ timeoutMs: 15000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(tab, null, 2));
if (printJsonResult(parent, tab)) {
return;
}
defaultRuntime.log(`opened: ${tab.url}\nid: ${tab.targetId}`);
@@ -329,13 +327,12 @@ export function registerBrowserManageCommands(
{
method: "POST",
path: "/tabs/focus",
query: profile ? { profile } : undefined,
query: resolveProfileQuery(profile),
body: { targetId },
},
{ timeoutMs: 5000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
if (printJsonResult(parent, { ok: true })) {
return;
}
defaultRuntime.log(`focused tab ${targetId}`);
@@ -356,7 +353,7 @@ export function registerBrowserManageCommands(
{
method: "DELETE",
path: `/tabs/${encodeURIComponent(targetId.trim())}`,
query: profile ? { profile } : undefined,
query: resolveProfileQuery(profile),
},
{ timeoutMs: 5000 },
);
@@ -366,14 +363,13 @@ export function registerBrowserManageCommands(
{
method: "POST",
path: "/act",
query: profile ? { profile } : undefined,
query: resolveProfileQuery(profile),
body: { kind: "close" },
},
{ timeoutMs: 20000 },
);
}
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
if (printJsonResult(parent, { ok: true })) {
return;
}
defaultRuntime.log("closed tab");
@@ -396,8 +392,7 @@ export function registerBrowserManageCommands(
{ timeoutMs: 3000 },
);
const profiles = result.profiles ?? [];
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ profiles }, null, 2));
if (printJsonResult(parent, { profiles })) {
return;
}
if (profiles.length === 0) {
@@ -444,8 +439,7 @@ export function registerBrowserManageCommands(
},
{ timeoutMs: 10_000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
if (printJsonResult(parent, result)) {
return;
}
const loc = result.isRemote ? ` cdpUrl: ${result.cdpUrl}` : ` port: ${result.cdpPort}`;
@@ -475,8 +469,7 @@ export function registerBrowserManageCommands(
},
{ timeoutMs: 20_000 },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
if (printJsonResult(parent, result)) {
return;
}
const msg = result.deleted

View File

@@ -1,5 +1,6 @@
import { Command } from "commander";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js";
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
type NodeInvokeCall = {
@@ -40,25 +41,7 @@ const callGateway = vi.fn(async (opts: NodeInvokeCall) => {
cwd?: unknown;
agentId?: unknown;
};
const argv = Array.isArray(params.command)
? params.command.map((entry) => String(entry))
: [];
const rawCommand =
typeof params.rawCommand === "string" && params.rawCommand.trim().length > 0
? params.rawCommand
: null;
return {
payload: {
cmdText: rawCommand ?? argv.join(" "),
plan: {
argv,
cwd: typeof params.cwd === "string" ? params.cwd : null,
rawCommand,
agentId: typeof params.agentId === "string" ? params.agentId : null,
sessionKey: null,
},
},
};
return buildSystemRunPreparePayload(params);
}
return {
payload: {

View File

@@ -2,12 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { enqueueKeyedTask, KeyedAsyncQueue } from "./keyed-async-queue.js";
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
const { promise, resolve, reject } = Promise.withResolvers<T>();
return { promise, resolve, reject };
}

View File

@@ -3,6 +3,7 @@ import type { Context } from "@mariozechner/pi-ai/dist/types.js";
import { describe, expect, it } from "vitest";
import {
asRecord,
expectConvertedRoles,
makeGeminiCliAssistantMessage,
makeGeminiCliModel,
makeGoogleAssistantMessage,
@@ -31,10 +32,7 @@ describe("google-shared convertTools", () => {
} as unknown as Context;
const contents = convertMessages(model, context);
expect(contents).toHaveLength(3);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model");
expect(contents[2].role).toBe("model");
expectConvertedRoles(contents, ["user", "model", "model"]);
const toolCallPart = contents[2].parts?.find(
(part) => typeof part === "object" && part !== null && "functionCall" in part,
);

View File

@@ -3,6 +3,7 @@ import type { Context, Tool } from "@mariozechner/pi-ai/dist/types.js";
import { describe, expect, it } from "vitest";
import {
asRecord,
expectConvertedRoles,
getFirstToolParameters,
makeGoogleAssistantMessage,
makeModel,
@@ -232,10 +233,7 @@ describe("google-shared convertMessages", () => {
} as unknown as Context;
const contents = convertMessages(model, context);
expect(contents).toHaveLength(3);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model");
expect(contents[2].role).toBe("model");
expectConvertedRoles(contents, ["user", "model", "model"]);
expect(contents[1].parts).toHaveLength(1);
expect(contents[2].parts).toHaveLength(1);
});

View File

@@ -74,3 +74,10 @@ export function makeGeminiCliAssistantMessage(model: string, content: unknown) {
timestamp: 0,
};
}
export function expectConvertedRoles(contents: Array<{ role?: string }>, expectedRoles: string[]) {
expect(contents).toHaveLength(expectedRoles.length);
for (const [index, role] of expectedRoles.entries()) {
expect(contents[index]?.role).toBe(role);
}
}

View File

@@ -0,0 +1,27 @@
type SystemRunPrepareInput = {
command?: unknown;
rawCommand?: unknown;
cwd?: unknown;
agentId?: unknown;
sessionKey?: unknown;
};
export function buildSystemRunPreparePayload(params: SystemRunPrepareInput) {
const argv = Array.isArray(params.command) ? params.command.map(String) : [];
const rawCommand =
typeof params.rawCommand === "string" && params.rawCommand.trim().length > 0
? params.rawCommand
: null;
return {
payload: {
cmdText: rawCommand ?? argv.join(" "),
plan: {
argv,
cwd: typeof params.cwd === "string" ? params.cwd : null,
rawCommand,
agentId: typeof params.agentId === "string" ? params.agentId : null,
sessionKey: typeof params.sessionKey === "string" ? params.sessionKey : null,
},
},
};
}