Merge branch 'main' into nanogpt
This commit is contained in:
317 files changed
+6416
-23143
No files matched your search
@@ -0,0 +1,164 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveApiKeyForProfile } from "./oauth.js";
|
||||
import { ensureAuthProfileStore } from "./store.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
|
||||
describe("resolveApiKeyForProfile fallback to main agent", () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
let tmpDir: string;
|
||||
let mainAgentDir: string;
|
||||
let secondaryAgentDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "oauth-fallback-test-"));
|
||||
mainAgentDir = path.join(tmpDir, "agents", "main", "agent");
|
||||
secondaryAgentDir = path.join(tmpDir, "agents", "kids", "agent");
|
||||
await fs.mkdir(mainAgentDir, { recursive: true });
|
||||
await fs.mkdir(secondaryAgentDir, { recursive: true });
|
||||
|
||||
// Set environment variables so resolveClawdbotAgentDir() returns mainAgentDir
|
||||
process.env.CLAWDBOT_STATE_DIR = tmpDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = mainAgentDir;
|
||||
process.env.PI_CODING_AGENT_DIR = mainAgentDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
// Restore original environment
|
||||
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("falls back to main agent credentials when secondary agent token is expired and refresh fails", async () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
const now = Date.now();
|
||||
const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
|
||||
const freshTime = now + 60 * 60 * 1000; // 1 hour from now
|
||||
|
||||
// Write expired credentials for secondary agent
|
||||
const secondaryStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "expired-access-token",
|
||||
refresh: "expired-refresh-token",
|
||||
expires: expiredTime,
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(secondaryAgentDir, "auth-profiles.json"),
|
||||
JSON.stringify(secondaryStore),
|
||||
);
|
||||
|
||||
// Write fresh credentials for main agent
|
||||
const mainStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "fresh-access-token",
|
||||
refresh: "fresh-refresh-token",
|
||||
expires: freshTime,
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
|
||||
|
||||
// Mock fetch to simulate OAuth refresh failure
|
||||
const fetchSpy = vi.fn(async () => {
|
||||
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
// Load the secondary agent's store (will merge with main agent's store)
|
||||
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
|
||||
|
||||
// Call resolveApiKeyForProfile with the secondary agent's expired credentials
|
||||
// This should:
|
||||
// 1. Try to refresh the expired token (fails due to mocked fetch)
|
||||
// 2. Fall back to main agent's fresh credentials
|
||||
// 3. Copy those credentials to the secondary agent
|
||||
const result = await resolveApiKeyForProfile({
|
||||
store: loadedSecondaryStore,
|
||||
profileId,
|
||||
agentDir: secondaryAgentDir,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.apiKey).toBe("fresh-access-token");
|
||||
expect(result?.provider).toBe("anthropic");
|
||||
|
||||
// Verify the credentials were copied to the secondary agent
|
||||
const updatedSecondaryStore = JSON.parse(
|
||||
await fs.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
|
||||
) as AuthProfileStore;
|
||||
expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
|
||||
access: "fresh-access-token",
|
||||
expires: freshTime,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws error when both secondary and main agent credentials are expired", async () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
const now = Date.now();
|
||||
const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
|
||||
|
||||
// Write expired credentials for both agents
|
||||
const expiredStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "expired-access-token",
|
||||
refresh: "expired-refresh-token",
|
||||
expires: expiredTime,
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(secondaryAgentDir, "auth-profiles.json"),
|
||||
JSON.stringify(expiredStore),
|
||||
);
|
||||
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(expiredStore));
|
||||
|
||||
// Mock fetch to simulate OAuth refresh failure
|
||||
const fetchSpy = vi.fn(async () => {
|
||||
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
|
||||
|
||||
// Should throw because both agents have expired credentials
|
||||
await expect(
|
||||
resolveApiKeyForProfile({
|
||||
store: loadedSecondaryStore,
|
||||
profileId,
|
||||
agentDir: secondaryAgentDir,
|
||||
}),
|
||||
).rejects.toThrow(/OAuth token refresh failed/);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import lockfile from "proper-lockfile";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { refreshChutesTokens } from "../chutes-oauth.js";
|
||||
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
|
||||
import { AUTH_STORE_LOCK_OPTIONS } from "./constants.js";
|
||||
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
|
||||
import { formatAuthDoctorHint } from "./doctor.js";
|
||||
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
|
||||
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
|
||||
@@ -196,6 +196,32 @@ export async function resolveApiKeyForProfile(params: {
|
||||
// keep original error
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if this is a secondary agent, try using the main agent's credentials
|
||||
if (params.agentDir) {
|
||||
try {
|
||||
const mainStore = ensureAuthProfileStore(undefined); // main agent (no agentDir)
|
||||
const mainCred = mainStore.profiles[profileId];
|
||||
if (mainCred?.type === "oauth" && Date.now() < mainCred.expires) {
|
||||
// Main agent has fresh credentials - copy them to this agent and use them
|
||||
refreshedStore.profiles[profileId] = { ...mainCred };
|
||||
saveAuthProfileStore(refreshedStore, params.agentDir);
|
||||
log.info("inherited fresh OAuth credentials from main agent", {
|
||||
profileId,
|
||||
agentDir: params.agentDir,
|
||||
expires: new Date(mainCred.expires).toISOString(),
|
||||
});
|
||||
return {
|
||||
apiKey: buildOAuthApiKey(mainCred.provider, mainCred),
|
||||
provider: mainCred.provider,
|
||||
email: mainCred.email,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// keep original error if main agent fallback also fails
|
||||
}
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const hint = formatAuthDoctorHint({
|
||||
cfg,
|
||||
|
||||
@@ -60,11 +60,18 @@ export function buildDockerExecArgs(params: {
|
||||
for (const [key, value] of Object.entries(params.env)) {
|
||||
args.push("-e", `${key}=${value}`);
|
||||
}
|
||||
const hasCustomPath = typeof params.env.PATH === "string" && params.env.PATH.length > 0;
|
||||
if (hasCustomPath) {
|
||||
// Avoid interpolating PATH into the shell command; pass it via env instead.
|
||||
args.push("-e", `CLAWDBOT_PREPEND_PATH=${params.env.PATH}`);
|
||||
}
|
||||
// Login shell (-l) sources /etc/profile which resets PATH to a minimal set,
|
||||
// overriding both Docker ENV and -e PATH=... environment variables.
|
||||
// Prepend custom PATH after profile sourcing to ensure custom tools are accessible
|
||||
// while preserving system paths that /etc/profile may have added.
|
||||
const pathExport = params.env.PATH ? `export PATH="${params.env.PATH}:$PATH"; ` : "";
|
||||
const pathExport = hasCustomPath
|
||||
? 'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; '
|
||||
: "";
|
||||
args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`);
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -318,9 +318,30 @@ describe("buildDockerExecArgs", () => {
|
||||
});
|
||||
|
||||
const commandArg = args[args.length - 1];
|
||||
expect(commandArg).toContain('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"');
|
||||
expect(args).toContain("CLAWDBOT_PREPEND_PATH=/custom/bin:/usr/local/bin:/usr/bin");
|
||||
expect(commandArg).toContain('export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"');
|
||||
expect(commandArg).toContain("echo hello");
|
||||
expect(commandArg).toBe('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"; echo hello');
|
||||
expect(commandArg).toBe(
|
||||
'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; echo hello',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not interpolate PATH into the shell command", () => {
|
||||
const injectedPath = "$(touch /tmp/clawdbot-path-injection)";
|
||||
const args = buildDockerExecArgs({
|
||||
containerName: "test-container",
|
||||
command: "echo hello",
|
||||
env: {
|
||||
PATH: injectedPath,
|
||||
HOME: "/home/user",
|
||||
},
|
||||
tty: false,
|
||||
});
|
||||
|
||||
const commandArg = args[args.length - 1];
|
||||
expect(args).toContain(`CLAWDBOT_PREPEND_PATH=${injectedPath}`);
|
||||
expect(commandArg).not.toContain(injectedPath);
|
||||
expect(commandArg).toContain("CLAWDBOT_PREPEND_PATH");
|
||||
});
|
||||
|
||||
it("does not add PATH export when PATH is not in env", () => {
|
||||
|
||||
@@ -20,8 +20,10 @@ describe("gateway tool", () => {
|
||||
vi.useFakeTimers();
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousProfile = process.env.CLAWDBOT_PROFILE;
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||
process.env.CLAWDBOT_PROFILE = "isolated";
|
||||
|
||||
try {
|
||||
const tool = createClawdbotTools({
|
||||
@@ -47,7 +49,9 @@ describe("gateway tool", () => {
|
||||
payload?: { kind?: string; doctorHint?: string | null };
|
||||
};
|
||||
expect(parsed.payload?.kind).toBe("restart");
|
||||
expect(parsed.payload?.doctorHint).toBe("Run: clawdbot doctor --non-interactive");
|
||||
expect(parsed.payload?.doctorHint).toBe(
|
||||
"Run: clawdbot --profile isolated doctor --non-interactive",
|
||||
);
|
||||
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -60,6 +64,11 @@ describe("gateway tool", () => {
|
||||
} else {
|
||||
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousProfile === undefined) {
|
||||
delete process.env.CLAWDBOT_PROFILE;
|
||||
} else {
|
||||
process.env.CLAWDBOT_PROFILE = previousProfile;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -20,11 +20,8 @@ import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
||||
import { createTtsTool } from "./tools/tts-tool.js";
|
||||
|
||||
export function createClawdbotTools(options?: {
|
||||
browserControlUrl?: string;
|
||||
sandboxBrowserBridgeUrl?: string;
|
||||
allowHostBrowserControl?: boolean;
|
||||
allowedControlUrls?: string[];
|
||||
allowedControlHosts?: string[];
|
||||
allowedControlPorts?: number[];
|
||||
agentSessionKey?: string;
|
||||
agentChannel?: GatewayMessageChannel;
|
||||
agentAccountId?: string;
|
||||
@@ -75,11 +72,8 @@ export function createClawdbotTools(options?: {
|
||||
});
|
||||
const tools: AnyAgentTool[] = [
|
||||
createBrowserTool({
|
||||
defaultControlUrl: options?.browserControlUrl,
|
||||
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
|
||||
allowHostControl: options?.allowHostBrowserControl,
|
||||
allowedControlUrls: options?.allowedControlUrls,
|
||||
allowedControlHosts: options?.allowedControlHosts,
|
||||
allowedControlPorts: options?.allowedControlPorts,
|
||||
}),
|
||||
createCanvasTool(),
|
||||
createNodesTool({
|
||||
|
||||
@@ -103,5 +103,47 @@ describe("pruneHistoryForContextShare", () => {
|
||||
expect(pruned.droppedChunks).toBe(0);
|
||||
expect(pruned.messages.length).toBe(messages.length);
|
||||
expect(pruned.keptTokens).toBe(estimateMessagesTokens(messages));
|
||||
expect(pruned.droppedMessagesList).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns droppedMessagesList containing dropped messages", () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeMessage(1, 4000),
|
||||
makeMessage(2, 4000),
|
||||
makeMessage(3, 4000),
|
||||
makeMessage(4, 4000),
|
||||
];
|
||||
const maxContextTokens = 2000; // budget is 1000 tokens (50%)
|
||||
const pruned = pruneHistoryForContextShare({
|
||||
messages,
|
||||
maxContextTokens,
|
||||
maxHistoryShare: 0.5,
|
||||
parts: 2,
|
||||
});
|
||||
|
||||
expect(pruned.droppedChunks).toBeGreaterThan(0);
|
||||
expect(pruned.droppedMessagesList.length).toBe(pruned.droppedMessages);
|
||||
|
||||
// All messages accounted for: kept + dropped = original
|
||||
const allIds = [
|
||||
...pruned.droppedMessagesList.map((m) => m.timestamp),
|
||||
...pruned.messages.map((m) => m.timestamp),
|
||||
].sort((a, b) => a - b);
|
||||
const originalIds = messages.map((m) => m.timestamp).sort((a, b) => a - b);
|
||||
expect(allIds).toEqual(originalIds);
|
||||
});
|
||||
|
||||
it("returns empty droppedMessagesList when no pruning needed", () => {
|
||||
const messages: AgentMessage[] = [makeMessage(1, 100)];
|
||||
const pruned = pruneHistoryForContextShare({
|
||||
messages,
|
||||
maxContextTokens: 100_000,
|
||||
maxHistoryShare: 0.5,
|
||||
parts: 2,
|
||||
});
|
||||
|
||||
expect(pruned.droppedChunks).toBe(0);
|
||||
expect(pruned.droppedMessagesList).toEqual([]);
|
||||
expect(pruned.messages.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -301,6 +301,7 @@ export function pruneHistoryForContextShare(params: {
|
||||
parts?: number;
|
||||
}): {
|
||||
messages: AgentMessage[];
|
||||
droppedMessagesList: AgentMessage[];
|
||||
droppedChunks: number;
|
||||
droppedMessages: number;
|
||||
droppedTokens: number;
|
||||
@@ -310,6 +311,7 @@ export function pruneHistoryForContextShare(params: {
|
||||
const maxHistoryShare = params.maxHistoryShare ?? 0.5;
|
||||
const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare));
|
||||
let keptMessages = params.messages;
|
||||
const allDroppedMessages: AgentMessage[] = [];
|
||||
let droppedChunks = 0;
|
||||
let droppedMessages = 0;
|
||||
let droppedTokens = 0;
|
||||
@@ -323,11 +325,13 @@ export function pruneHistoryForContextShare(params: {
|
||||
droppedChunks += 1;
|
||||
droppedMessages += dropped.length;
|
||||
droppedTokens += estimateMessagesTokens(dropped);
|
||||
allDroppedMessages.push(...dropped);
|
||||
keptMessages = rest.flat();
|
||||
}
|
||||
|
||||
return {
|
||||
messages: keptMessages,
|
||||
droppedMessagesList: allDroppedMessages,
|
||||
droppedChunks,
|
||||
droppedMessages,
|
||||
droppedTokens,
|
||||
|
||||
@@ -119,9 +119,16 @@ function mergeConfig(
|
||||
const provider = overrides?.provider ?? defaults?.provider ?? "auto";
|
||||
const defaultRemote = defaults?.remote;
|
||||
const overrideRemote = overrides?.remote;
|
||||
const hasRemote = Boolean(defaultRemote || overrideRemote);
|
||||
const hasRemoteConfig = Boolean(
|
||||
overrideRemote?.baseUrl ||
|
||||
overrideRemote?.apiKey ||
|
||||
overrideRemote?.headers ||
|
||||
defaultRemote?.baseUrl ||
|
||||
defaultRemote?.apiKey ||
|
||||
defaultRemote?.headers,
|
||||
);
|
||||
const includeRemote =
|
||||
hasRemote || provider === "openai" || provider === "gemini" || provider === "auto";
|
||||
hasRemoteConfig || provider === "openai" || provider === "gemini" || provider === "auto";
|
||||
const batch = {
|
||||
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
|
||||
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles.js";
|
||||
import { saveAuthProfileStore } from "./auth-profiles.js";
|
||||
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
|
||||
import { runWithModelFallback } from "./model-fallback.js";
|
||||
|
||||
function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
|
||||
@@ -117,6 +124,122 @@ describe("runWithModelFallback", () => {
|
||||
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
|
||||
});
|
||||
|
||||
it("skips providers when all profiles are in cooldown", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||
const provider = `cooldown-test-${crypto.randomUUID()}`;
|
||||
const profileId = `${provider}:default`;
|
||||
|
||||
const store: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "api_key",
|
||||
provider,
|
||||
key: "test-key",
|
||||
},
|
||||
},
|
||||
usageStats: {
|
||||
[profileId]: {
|
||||
cooldownUntil: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
saveAuthProfileStore(store, tempDir);
|
||||
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: `${provider}/m1`,
|
||||
fallbacks: ["fallback/ok-model"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const run = vi.fn().mockImplementation(async (providerId, modelId) => {
|
||||
if (providerId === "fallback") return "ok";
|
||||
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider,
|
||||
model: "m1",
|
||||
agentDir: tempDir,
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run.mock.calls).toEqual([["fallback", "ok-model"]]);
|
||||
expect(result.attempts[0]?.reason).toBe("rate_limit");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not skip when any profile is available", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||
const provider = `cooldown-mixed-${crypto.randomUUID()}`;
|
||||
const profileA = `${provider}:a`;
|
||||
const profileB = `${provider}:b`;
|
||||
|
||||
const store: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
[profileA]: {
|
||||
type: "api_key",
|
||||
provider,
|
||||
key: "key-a",
|
||||
},
|
||||
[profileB]: {
|
||||
type: "api_key",
|
||||
provider,
|
||||
key: "key-b",
|
||||
},
|
||||
},
|
||||
usageStats: {
|
||||
[profileA]: {
|
||||
cooldownUntil: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
saveAuthProfileStore(store, tempDir);
|
||||
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: `${provider}/m1`,
|
||||
fallbacks: ["fallback/ok-model"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const run = vi.fn().mockImplementation(async (providerId) => {
|
||||
if (providerId === provider) return "ok";
|
||||
return "unexpected";
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider,
|
||||
model: "m1",
|
||||
agentDir: tempDir,
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run.mock.calls).toEqual([[provider, "m1"]]);
|
||||
expect(result.attempts).toEqual([]);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not append configured primary when fallbacksOverride is set", async () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
resolveModelRefFromString,
|
||||
} from "./model-selection.js";
|
||||
import type { FailoverReason } from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
isProfileInCooldown,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
|
||||
type ModelCandidate = {
|
||||
provider: string;
|
||||
@@ -189,6 +194,7 @@ export async function runWithModelFallback<T>(params: {
|
||||
cfg: ClawdbotConfig | undefined;
|
||||
provider: string;
|
||||
model: string;
|
||||
agentDir?: string;
|
||||
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
|
||||
fallbacksOverride?: string[];
|
||||
run: (provider: string, model: string) => Promise<T>;
|
||||
@@ -211,11 +217,33 @@ export async function runWithModelFallback<T>(params: {
|
||||
model: params.model,
|
||||
fallbacksOverride: params.fallbacksOverride,
|
||||
});
|
||||
const authStore = params.cfg
|
||||
? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false })
|
||||
: null;
|
||||
const attempts: FallbackAttempt[] = [];
|
||||
let lastError: unknown;
|
||||
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
const candidate = candidates[i] as ModelCandidate;
|
||||
if (authStore) {
|
||||
const profileIds = resolveAuthProfileOrder({
|
||||
cfg: params.cfg,
|
||||
store: authStore,
|
||||
provider: candidate.provider,
|
||||
});
|
||||
const isAnyProfileAvailable = profileIds.some((id) => !isProfileInCooldown(authStore, id));
|
||||
|
||||
if (profileIds.length > 0 && !isAnyProfileAvailable) {
|
||||
// All profiles for this provider are in cooldown; skip without attempting
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`,
|
||||
reason: "rate_limit",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await params.run(candidate.provider, candidate.model);
|
||||
return {
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("buildEmbeddedSandboxInfo", () => {
|
||||
},
|
||||
browserAllowHostControl: true,
|
||||
browser: {
|
||||
controlUrl: "http://localhost:9222",
|
||||
bridgeUrl: "http://localhost:9222",
|
||||
noVncUrl: "http://localhost:6080",
|
||||
containerName: "clawdbot-sbx-browser-test",
|
||||
},
|
||||
@@ -138,7 +138,7 @@ describe("buildEmbeddedSandboxInfo", () => {
|
||||
workspaceDir: "/tmp/clawdbot-sandbox",
|
||||
workspaceAccess: "none",
|
||||
agentWorkspaceMount: undefined,
|
||||
browserControlUrl: "http://localhost:9222",
|
||||
browserBridgeUrl: "http://localhost:9222",
|
||||
browserNoVncUrl: "http://localhost:6080",
|
||||
hostBrowserAllowed: true,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveContextWindowInfo } from "../context-window-guard.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||
import { setCompactionSafeguardRuntime } from "../pi-extensions/compaction-safeguard-runtime.js";
|
||||
import { setContextPruningRuntime } from "../pi-extensions/context-pruning/runtime.js";
|
||||
import { computeEffectiveSettings } from "../pi-extensions/context-pruning/settings.js";
|
||||
import { makeToolPrunablePredicate } from "../pi-extensions/context-pruning/tools.js";
|
||||
@@ -75,6 +76,10 @@ export function buildEmbeddedExtensionPaths(params: {
|
||||
}): string[] {
|
||||
const paths: string[] = [];
|
||||
if (resolveCompactionMode(params.cfg) === "safeguard") {
|
||||
const compactionCfg = params.cfg?.agents?.defaults?.compaction;
|
||||
setCompactionSafeguardRuntime(params.sessionManager, {
|
||||
maxHistoryShare: compactionCfg?.maxHistoryShare,
|
||||
});
|
||||
paths.push(resolvePiExtensionPath("compaction-safeguard"));
|
||||
}
|
||||
const pruning = buildContextPruningExtension(params);
|
||||
|
||||
@@ -215,6 +215,10 @@ export async function runEmbeddedAttempt(
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
|
||||
@@ -35,6 +35,10 @@ export type RunEmbeddedPiAgentParams = {
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
/** Current channel ID for auto-threading (Slack). */
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
|
||||
@@ -31,6 +31,10 @@ export type EmbeddedRunAttemptParams = {
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string;
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
|
||||
@@ -13,12 +13,9 @@ export function buildEmbeddedSandboxInfo(
|
||||
workspaceDir: sandbox.workspaceDir,
|
||||
workspaceAccess: sandbox.workspaceAccess,
|
||||
agentWorkspaceMount: sandbox.workspaceAccess === "ro" ? "/agent" : undefined,
|
||||
browserControlUrl: sandbox.browser?.controlUrl,
|
||||
browserBridgeUrl: sandbox.browser?.bridgeUrl,
|
||||
browserNoVncUrl: sandbox.browser?.noVncUrl,
|
||||
hostBrowserAllowed: sandbox.browserAllowHostControl,
|
||||
allowedControlUrls: sandbox.browserAllowedControlUrls,
|
||||
allowedControlHosts: sandbox.browserAllowedControlHosts,
|
||||
allowedControlPorts: sandbox.browserAllowedControlPorts,
|
||||
...(elevatedAllowed
|
||||
? {
|
||||
elevated: {
|
||||
|
||||
@@ -69,12 +69,9 @@ export type EmbeddedSandboxInfo = {
|
||||
workspaceDir?: string;
|
||||
workspaceAccess?: "none" | "ro" | "rw";
|
||||
agentWorkspaceMount?: string;
|
||||
browserControlUrl?: string;
|
||||
browserBridgeUrl?: string;
|
||||
browserNoVncUrl?: string;
|
||||
hostBrowserAllowed?: boolean;
|
||||
allowedControlUrls?: string[];
|
||||
allowedControlHosts?: string[];
|
||||
allowedControlPorts?: number[];
|
||||
elevated?: {
|
||||
allowed: boolean;
|
||||
defaultLevel: "on" | "off" | "ask" | "full";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export type CompactionSafeguardRuntimeValue = {
|
||||
maxHistoryShare?: number;
|
||||
};
|
||||
|
||||
// Session-scoped runtime registry keyed by object identity.
|
||||
// Follows the same WeakMap pattern as context-pruning/runtime.ts.
|
||||
const REGISTRY = new WeakMap<object, CompactionSafeguardRuntimeValue>();
|
||||
|
||||
export function setCompactionSafeguardRuntime(
|
||||
sessionManager: unknown,
|
||||
value: CompactionSafeguardRuntimeValue | null,
|
||||
): void {
|
||||
if (!sessionManager || typeof sessionManager !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = sessionManager as object;
|
||||
if (value === null) {
|
||||
REGISTRY.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
REGISTRY.set(key, value);
|
||||
}
|
||||
|
||||
export function getCompactionSafeguardRuntime(
|
||||
sessionManager: unknown,
|
||||
): CompactionSafeguardRuntimeValue | null {
|
||||
if (!sessionManager || typeof sessionManager !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return REGISTRY.get(sessionManager as object) ?? null;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getCompactionSafeguardRuntime,
|
||||
setCompactionSafeguardRuntime,
|
||||
} from "./compaction-safeguard-runtime.js";
|
||||
import { __testing } from "./compaction-safeguard.js";
|
||||
|
||||
const {
|
||||
@@ -208,3 +212,41 @@ describe("isOversizedForSummary", () => {
|
||||
expect(typeof isOversized).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compaction-safeguard runtime registry", () => {
|
||||
it("stores and retrieves config by session manager identity", () => {
|
||||
const sm = {};
|
||||
setCompactionSafeguardRuntime(sm, { maxHistoryShare: 0.3 });
|
||||
const runtime = getCompactionSafeguardRuntime(sm);
|
||||
expect(runtime).toEqual({ maxHistoryShare: 0.3 });
|
||||
});
|
||||
|
||||
it("returns null for unknown session manager", () => {
|
||||
const sm = {};
|
||||
expect(getCompactionSafeguardRuntime(sm)).toBeNull();
|
||||
});
|
||||
|
||||
it("clears entry when value is null", () => {
|
||||
const sm = {};
|
||||
setCompactionSafeguardRuntime(sm, { maxHistoryShare: 0.7 });
|
||||
expect(getCompactionSafeguardRuntime(sm)).not.toBeNull();
|
||||
setCompactionSafeguardRuntime(sm, null);
|
||||
expect(getCompactionSafeguardRuntime(sm)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores non-object session managers", () => {
|
||||
setCompactionSafeguardRuntime(null, { maxHistoryShare: 0.5 });
|
||||
expect(getCompactionSafeguardRuntime(null)).toBeNull();
|
||||
setCompactionSafeguardRuntime(undefined, { maxHistoryShare: 0.5 });
|
||||
expect(getCompactionSafeguardRuntime(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("isolates different session managers", () => {
|
||||
const sm1 = {};
|
||||
const sm2 = {};
|
||||
setCompactionSafeguardRuntime(sm1, { maxHistoryShare: 0.3 });
|
||||
setCompactionSafeguardRuntime(sm2, { maxHistoryShare: 0.8 });
|
||||
expect(getCompactionSafeguardRuntime(sm1)).toEqual({ maxHistoryShare: 0.3 });
|
||||
expect(getCompactionSafeguardRuntime(sm2)).toEqual({ maxHistoryShare: 0.8 });
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveContextWindowTokens,
|
||||
summarizeInStages,
|
||||
} from "../compaction.js";
|
||||
import { getCompactionSafeguardRuntime } from "./compaction-safeguard-runtime.js";
|
||||
const FALLBACK_SUMMARY =
|
||||
"Summary unavailable due to context limits. Older messages were truncated.";
|
||||
const TURN_PREFIX_INSTRUCTIONS =
|
||||
@@ -174,21 +175,28 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
|
||||
let messagesToSummarize = preparation.messagesToSummarize;
|
||||
|
||||
const runtime = getCompactionSafeguardRuntime(ctx.sessionManager);
|
||||
const maxHistoryShare = runtime?.maxHistoryShare ?? 0.5;
|
||||
|
||||
const tokensBefore =
|
||||
typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore)
|
||||
? preparation.tokensBefore
|
||||
: undefined;
|
||||
|
||||
let droppedSummary: string | undefined;
|
||||
|
||||
if (tokensBefore !== undefined) {
|
||||
const summarizableTokens =
|
||||
estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages);
|
||||
const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens));
|
||||
const maxHistoryTokens = Math.floor(contextWindowTokens * 0.5);
|
||||
// Apply SAFETY_MARGIN so token underestimates don't trigger unnecessary pruning
|
||||
const maxHistoryTokens = Math.floor(contextWindowTokens * maxHistoryShare * SAFETY_MARGIN);
|
||||
|
||||
if (newContentTokens > maxHistoryTokens) {
|
||||
const pruned = pruneHistoryForContextShare({
|
||||
messages: messagesToSummarize,
|
||||
maxContextTokens: contextWindowTokens,
|
||||
maxHistoryShare: 0.5,
|
||||
maxHistoryShare,
|
||||
parts: 2,
|
||||
});
|
||||
if (pruned.droppedChunks > 0) {
|
||||
@@ -200,6 +208,37 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
`(${pruned.droppedMessages} messages) to fit history budget.`,
|
||||
);
|
||||
messagesToSummarize = pruned.messages;
|
||||
|
||||
// Summarize dropped messages so context isn't lost
|
||||
if (pruned.droppedMessagesList.length > 0) {
|
||||
try {
|
||||
const droppedChunkRatio = computeAdaptiveChunkRatio(
|
||||
pruned.droppedMessagesList,
|
||||
contextWindowTokens,
|
||||
);
|
||||
const droppedMaxChunkTokens = Math.max(
|
||||
1,
|
||||
Math.floor(contextWindowTokens * droppedChunkRatio),
|
||||
);
|
||||
droppedSummary = await summarizeInStages({
|
||||
messages: pruned.droppedMessagesList,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
reserveTokens: Math.max(1, Math.floor(preparation.settings.reserveTokens)),
|
||||
maxChunkTokens: droppedMaxChunkTokens,
|
||||
contextWindow: contextWindowTokens,
|
||||
customInstructions,
|
||||
previousSummary: preparation.previousSummary,
|
||||
});
|
||||
} catch (droppedError) {
|
||||
console.warn(
|
||||
`Compaction safeguard: failed to summarize dropped messages, continuing without: ${
|
||||
droppedError instanceof Error ? droppedError.message : String(droppedError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +249,10 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
|
||||
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
|
||||
|
||||
// Feed dropped-messages summary as previousSummary so the main summarization
|
||||
// incorporates context from pruned messages instead of losing it entirely.
|
||||
const effectivePreviousSummary = droppedSummary ?? preparation.previousSummary;
|
||||
|
||||
const historySummary = await summarizeInStages({
|
||||
messages: messagesToSummarize,
|
||||
model,
|
||||
@@ -219,7 +262,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
maxChunkTokens,
|
||||
contextWindow: contextWindowTokens,
|
||||
customInstructions,
|
||||
previousSummary: preparation.previousSummary,
|
||||
previousSummary: effectivePreviousSummary,
|
||||
});
|
||||
|
||||
let summary = historySummary;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
import type { SandboxDockerConfig } from "./sandbox.js";
|
||||
@@ -270,6 +271,75 @@ describe("Agent-specific tool filtering", () => {
|
||||
expect(defaultNames).not.toContain("exec");
|
||||
});
|
||||
|
||||
it("should apply per-sender tool policies for group tools", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groups: {
|
||||
"*": {
|
||||
tools: { allow: ["read"] },
|
||||
toolsBySender: {
|
||||
alice: { allow: ["read", "exec"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const aliceTools = createClawdbotCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:whatsapp:group:family",
|
||||
senderId: "alice",
|
||||
workspaceDir: "/tmp/test-group-sender",
|
||||
agentDir: "/tmp/agent-group-sender",
|
||||
});
|
||||
const aliceNames = aliceTools.map((t) => t.name);
|
||||
expect(aliceNames).toContain("read");
|
||||
expect(aliceNames).toContain("exec");
|
||||
|
||||
const bobTools = createClawdbotCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:whatsapp:group:family",
|
||||
senderId: "bob",
|
||||
workspaceDir: "/tmp/test-group-sender-bob",
|
||||
agentDir: "/tmp/agent-group-sender",
|
||||
});
|
||||
const bobNames = bobTools.map((t) => t.name);
|
||||
expect(bobNames).toContain("read");
|
||||
expect(bobNames).not.toContain("exec");
|
||||
});
|
||||
|
||||
it("should not let default sender policy override group tools", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groups: {
|
||||
"*": {
|
||||
toolsBySender: {
|
||||
admin: { allow: ["read", "exec"] },
|
||||
},
|
||||
},
|
||||
locked: {
|
||||
tools: { allow: ["read"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const adminTools = createClawdbotCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:whatsapp:group:locked",
|
||||
senderId: "admin",
|
||||
workspaceDir: "/tmp/test-group-default-override",
|
||||
agentDir: "/tmp/agent-group-default-override",
|
||||
});
|
||||
const adminNames = adminTools.map((t) => t.name);
|
||||
expect(adminNames).toContain("read");
|
||||
expect(adminNames).not.toContain("exec");
|
||||
});
|
||||
|
||||
it("should resolve telegram group tool policy for topic session keys", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
|
||||
-1
@@ -96,7 +96,6 @@ describe("createClawdbotCodingTools", () => {
|
||||
};
|
||||
expect(parameters.properties?.action).toBeDefined();
|
||||
expect(parameters.properties?.target).toBeDefined();
|
||||
expect(parameters.properties?.controlUrl).toBeDefined();
|
||||
expect(parameters.properties?.targetUrl).toBeDefined();
|
||||
expect(parameters.properties?.request).toBeDefined();
|
||||
expect(parameters.required ?? []).toContain("action");
|
||||
|
||||
@@ -233,6 +233,10 @@ export function resolveGroupToolPolicy(params: {
|
||||
groupChannel?: string | null;
|
||||
groupSpace?: string | null;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
}): SandboxToolPolicy | undefined {
|
||||
if (!params.config) return undefined;
|
||||
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
|
||||
@@ -255,12 +259,20 @@ export function resolveGroupToolPolicy(params: {
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
}) ??
|
||||
resolveChannelGroupToolsPolicy({
|
||||
cfg: params.config,
|
||||
channel,
|
||||
groupId,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
return pickToolPolicy(toolsConfig);
|
||||
}
|
||||
|
||||
@@ -140,6 +140,10 @@ export function createClawdbotCodingTools(options?: {
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent group policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
/** Reply-to mode for Slack auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
@@ -174,6 +178,10 @@ export function createClawdbotCodingTools(options?: {
|
||||
groupChannel: options?.groupChannel,
|
||||
groupSpace: options?.groupSpace,
|
||||
accountId: options?.agentAccountId,
|
||||
senderId: options?.senderId,
|
||||
senderName: options?.senderName,
|
||||
senderUsername: options?.senderUsername,
|
||||
senderE164: options?.senderE164,
|
||||
});
|
||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
||||
@@ -294,11 +302,8 @@ export function createClawdbotCodingTools(options?: {
|
||||
// Channel docking: include channel-defined agent tools (login, etc.).
|
||||
...listChannelAgentTools({ cfg: options?.config }),
|
||||
...createClawdbotTools({
|
||||
browserControlUrl: sandbox?.browser?.controlUrl,
|
||||
sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl,
|
||||
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
|
||||
allowedControlUrls: sandbox?.browserAllowedControlUrls,
|
||||
allowedControlHosts: sandbox?.browserAllowedControlHosts,
|
||||
allowedControlPorts: sandbox?.browserAllowedControlPorts,
|
||||
agentSessionKey: options?.sessionKey,
|
||||
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
|
||||
agentAccountId: options?.agentAccountId,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js";
|
||||
import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js";
|
||||
import { DEFAULT_CLAWD_BROWSER_COLOR } from "../../browser/constants.js";
|
||||
import {
|
||||
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||
} from "../../browser/constants.js";
|
||||
import { BROWSER_BRIDGES } from "./browser-bridges.js";
|
||||
import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
||||
import {
|
||||
@@ -39,14 +42,12 @@ function buildSandboxBrowserResolvedConfig(params: {
|
||||
controlPort: number;
|
||||
cdpPort: number;
|
||||
headless: boolean;
|
||||
evaluateEnabled: boolean;
|
||||
}): ResolvedBrowserConfig {
|
||||
const controlHost = "127.0.0.1";
|
||||
const controlUrl = `http://${controlHost}:${params.controlPort}`;
|
||||
const cdpHost = "127.0.0.1";
|
||||
return {
|
||||
enabled: true,
|
||||
controlUrl,
|
||||
controlHost,
|
||||
evaluateEnabled: params.evaluateEnabled,
|
||||
controlPort: params.controlPort,
|
||||
cdpProtocol: "http",
|
||||
cdpHost,
|
||||
@@ -80,6 +81,7 @@ export async function ensureSandboxBrowser(params: {
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
cfg: SandboxConfig;
|
||||
evaluateEnabled?: boolean;
|
||||
}): Promise<SandboxBrowserContext | null> {
|
||||
if (!params.cfg.browser.enabled) return null;
|
||||
if (!isToolAllowed(params.cfg.tools, "browser")) return null;
|
||||
@@ -174,6 +176,7 @@ export async function ensureSandboxBrowser(params: {
|
||||
controlPort: 0,
|
||||
cdpPort: mappedCdp,
|
||||
headless: params.cfg.browser.headless,
|
||||
evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||
}),
|
||||
onEnsureAttachTarget,
|
||||
});
|
||||
@@ -204,7 +207,7 @@ export async function ensureSandboxBrowser(params: {
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
controlUrl: resolvedBridge.baseUrl,
|
||||
bridgeUrl: resolvedBridge.baseUrl,
|
||||
noVncUrl,
|
||||
containerName,
|
||||
};
|
||||
|
||||
@@ -86,11 +86,6 @@ export function resolveSandboxBrowserConfig(params: {
|
||||
}): SandboxBrowserConfig {
|
||||
const agentBrowser = params.scope === "shared" ? undefined : params.agentBrowser;
|
||||
const globalBrowser = params.globalBrowser;
|
||||
const allowedControlUrls = agentBrowser?.allowedControlUrls ?? globalBrowser?.allowedControlUrls;
|
||||
const allowedControlHosts =
|
||||
agentBrowser?.allowedControlHosts ?? globalBrowser?.allowedControlHosts;
|
||||
const allowedControlPorts =
|
||||
agentBrowser?.allowedControlPorts ?? globalBrowser?.allowedControlPorts;
|
||||
return {
|
||||
enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false,
|
||||
image: agentBrowser?.image ?? globalBrowser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
|
||||
@@ -105,18 +100,6 @@ export function resolveSandboxBrowserConfig(params: {
|
||||
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
|
||||
enableNoVnc: agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
|
||||
allowHostControl: agentBrowser?.allowHostControl ?? globalBrowser?.allowHostControl ?? false,
|
||||
allowedControlUrls:
|
||||
Array.isArray(allowedControlUrls) && allowedControlUrls.length > 0
|
||||
? allowedControlUrls
|
||||
: undefined,
|
||||
allowedControlHosts:
|
||||
Array.isArray(allowedControlHosts) && allowedControlHosts.length > 0
|
||||
? allowedControlHosts
|
||||
: undefined,
|
||||
allowedControlPorts:
|
||||
Array.isArray(allowedControlPorts) && allowedControlPorts.length > 0
|
||||
? allowedControlPorts
|
||||
: undefined,
|
||||
autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true,
|
||||
autoStartTimeoutMs:
|
||||
agentBrowser?.autoStartTimeoutMs ??
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { DEFAULT_BROWSER_EVALUATE_ENABLED } from "../../browser/constants.js";
|
||||
import { syncSkillsToWorkspace } from "../skills.js";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js";
|
||||
import { ensureSandboxBrowser } from "./browser.js";
|
||||
@@ -69,11 +70,14 @@ export async function resolveSandboxContext(params: {
|
||||
cfg,
|
||||
});
|
||||
|
||||
const evaluateEnabled =
|
||||
params.config?.browser?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
|
||||
const browser = await ensureSandboxBrowser({
|
||||
scopeKey,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
cfg,
|
||||
evaluateEnabled,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -87,9 +91,6 @@ export async function resolveSandboxContext(params: {
|
||||
docker: cfg.docker,
|
||||
tools: cfg.tools,
|
||||
browserAllowHostControl: cfg.browser.allowHostControl,
|
||||
browserAllowedControlUrls: cfg.browser.allowedControlUrls,
|
||||
browserAllowedControlHosts: cfg.browser.allowedControlHosts,
|
||||
browserAllowedControlPorts: cfg.browser.allowedControlPorts,
|
||||
browser: browser ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,9 +37,6 @@ export type SandboxBrowserConfig = {
|
||||
headless: boolean;
|
||||
enableNoVnc: boolean;
|
||||
allowHostControl: boolean;
|
||||
allowedControlUrls?: string[];
|
||||
allowedControlHosts?: string[];
|
||||
allowedControlPorts?: number[];
|
||||
autoStart: boolean;
|
||||
autoStartTimeoutMs: number;
|
||||
};
|
||||
@@ -63,7 +60,7 @@ export type SandboxConfig = {
|
||||
};
|
||||
|
||||
export type SandboxBrowserContext = {
|
||||
controlUrl: string;
|
||||
bridgeUrl: string;
|
||||
noVncUrl?: string;
|
||||
containerName: string;
|
||||
};
|
||||
@@ -79,9 +76,6 @@ export type SandboxContext = {
|
||||
docker: SandboxDockerConfig;
|
||||
tools: SandboxToolPolicy;
|
||||
browserAllowHostControl: boolean;
|
||||
browserAllowedControlUrls?: string[];
|
||||
browserAllowedControlHosts?: string[];
|
||||
browserAllowedControlPorts?: number[];
|
||||
browser?: SandboxBrowserContext;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { acquireSessionWriteLock } from "./session-write-lock.js";
|
||||
import { __testing, acquireSessionWriteLock } from "./session-write-lock.js";
|
||||
|
||||
describe("acquireSessionWriteLock", () => {
|
||||
it("reuses locks across symlinked session paths", async () => {
|
||||
@@ -31,4 +31,132 @@ describe("acquireSessionWriteLock", () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the lock file until the last release", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
|
||||
try {
|
||||
const sessionFile = path.join(root, "sessions.json");
|
||||
const lockPath = `${sessionFile}.lock`;
|
||||
|
||||
const lockA = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
|
||||
const lockB = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
|
||||
|
||||
await expect(fs.access(lockPath)).resolves.toBeUndefined();
|
||||
await lockA.release();
|
||||
await expect(fs.access(lockPath)).resolves.toBeUndefined();
|
||||
await lockB.release();
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reclaims stale lock files", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
|
||||
try {
|
||||
const sessionFile = path.join(root, "sessions.json");
|
||||
const lockPath = `${sessionFile}.lock`;
|
||||
await fs.writeFile(
|
||||
lockPath,
|
||||
JSON.stringify({ pid: 123456, createdAt: new Date(Date.now() - 60_000).toISOString() }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 10 });
|
||||
const raw = await fs.readFile(lockPath, "utf8");
|
||||
const payload = JSON.parse(raw) as { pid: number };
|
||||
|
||||
expect(payload.pid).toBe(process.pid);
|
||||
await lock.release();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("removes held locks on termination signals", async () => {
|
||||
const signals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
|
||||
for (const signal of signals) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-cleanup-"));
|
||||
try {
|
||||
const sessionFile = path.join(root, "sessions.json");
|
||||
const lockPath = `${sessionFile}.lock`;
|
||||
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
|
||||
const keepAlive = () => {};
|
||||
if (signal === "SIGINT") {
|
||||
process.on(signal, keepAlive);
|
||||
}
|
||||
|
||||
__testing.handleTerminationSignal(signal);
|
||||
|
||||
await expect(fs.stat(lockPath)).rejects.toThrow();
|
||||
if (signal === "SIGINT") {
|
||||
process.off(signal, keepAlive);
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("registers cleanup for SIGQUIT and SIGABRT", () => {
|
||||
expect(__testing.cleanupSignals).toContain("SIGQUIT");
|
||||
expect(__testing.cleanupSignals).toContain("SIGABRT");
|
||||
});
|
||||
it("cleans up locks on SIGINT without removing other handlers", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
|
||||
const originalKill = process.kill.bind(process) as typeof process.kill;
|
||||
const killCalls: Array<NodeJS.Signals | undefined> = [];
|
||||
let otherHandlerCalled = false;
|
||||
|
||||
process.kill = ((pid: number, signal?: NodeJS.Signals) => {
|
||||
killCalls.push(signal);
|
||||
return true;
|
||||
}) as typeof process.kill;
|
||||
|
||||
const otherHandler = () => {
|
||||
otherHandlerCalled = true;
|
||||
};
|
||||
|
||||
process.on("SIGINT", otherHandler);
|
||||
|
||||
try {
|
||||
const sessionFile = path.join(root, "sessions.json");
|
||||
const lockPath = `${sessionFile}.lock`;
|
||||
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
|
||||
|
||||
process.emit("SIGINT");
|
||||
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
expect(otherHandlerCalled).toBe(true);
|
||||
expect(killCalls).toEqual([]);
|
||||
} finally {
|
||||
process.off("SIGINT", otherHandler);
|
||||
process.kill = originalKill;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans up locks on exit", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
|
||||
try {
|
||||
const sessionFile = path.join(root, "sessions.json");
|
||||
const lockPath = `${sessionFile}.lock`;
|
||||
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
|
||||
|
||||
process.emit("exit", 0);
|
||||
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("keeps other signal listeners registered", () => {
|
||||
const keepAlive = () => {};
|
||||
process.on("SIGINT", keepAlive);
|
||||
|
||||
__testing.handleTerminationSignal("SIGINT");
|
||||
|
||||
expect(process.listeners("SIGINT")).toContain(keepAlive);
|
||||
process.off("SIGINT", keepAlive);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -13,6 +14,9 @@ type HeldLock = {
|
||||
};
|
||||
|
||||
const HELD_LOCKS = new Map<string, HeldLock>();
|
||||
const CLEANUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
|
||||
type CleanupSignal = (typeof CLEANUP_SIGNALS)[number];
|
||||
const cleanupHandlers = new Map<CleanupSignal, () => void>();
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
if (!Number.isFinite(pid) || pid <= 0) return false;
|
||||
@@ -24,6 +28,65 @@ function isAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously release all held locks.
|
||||
* Used during process exit when async operations aren't reliable.
|
||||
*/
|
||||
function releaseAllLocksSync(): void {
|
||||
for (const [sessionFile, held] of HELD_LOCKS) {
|
||||
try {
|
||||
if (typeof held.handle.fd === "number") {
|
||||
fsSync.closeSync(held.handle.fd);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during cleanup - best effort
|
||||
}
|
||||
try {
|
||||
fsSync.rmSync(held.lockPath, { force: true });
|
||||
} catch {
|
||||
// Ignore errors during cleanup - best effort
|
||||
}
|
||||
HELD_LOCKS.delete(sessionFile);
|
||||
}
|
||||
}
|
||||
|
||||
let cleanupRegistered = false;
|
||||
|
||||
function handleTerminationSignal(signal: CleanupSignal): void {
|
||||
releaseAllLocksSync();
|
||||
const shouldReraise = process.listenerCount(signal) === 1;
|
||||
if (shouldReraise) {
|
||||
const handler = cleanupHandlers.get(signal);
|
||||
if (handler) process.off(signal, handler);
|
||||
try {
|
||||
process.kill(process.pid, signal);
|
||||
} catch {
|
||||
// Ignore errors during shutdown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerCleanupHandlers(): void {
|
||||
if (cleanupRegistered) return;
|
||||
cleanupRegistered = true;
|
||||
|
||||
// Cleanup on normal exit and process.exit() calls
|
||||
process.on("exit", () => {
|
||||
releaseAllLocksSync();
|
||||
});
|
||||
|
||||
// Handle termination signals
|
||||
for (const signal of CLEANUP_SIGNALS) {
|
||||
try {
|
||||
const handler = () => handleTerminationSignal(signal);
|
||||
cleanupHandlers.set(signal, handler);
|
||||
process.on(signal, handler);
|
||||
} catch {
|
||||
// Ignore unsupported signals on this platform.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readLockPayload(lockPath: string): Promise<LockFilePayload | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(lockPath, "utf8");
|
||||
@@ -43,6 +106,7 @@ export async function acquireSessionWriteLock(params: {
|
||||
}): Promise<{
|
||||
release: () => Promise<void>;
|
||||
}> {
|
||||
registerCleanupHandlers();
|
||||
const timeoutMs = params.timeoutMs ?? 10_000;
|
||||
const staleMs = params.staleMs ?? 30 * 60 * 1000;
|
||||
const sessionFile = path.resolve(params.sessionFile);
|
||||
@@ -116,3 +180,9 @@ export async function acquireSessionWriteLock(params: {
|
||||
const owner = payload?.pid ? `pid=${payload.pid}` : "unknown";
|
||||
throw new Error(`session file locked (timeout ${timeoutMs}ms): ${owner} ${lockPath}`);
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
cleanupSignals: [...CLEANUP_SIGNALS],
|
||||
handleTerminationSignal,
|
||||
releaseAllLocksSync,
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import type { SkillEligibilityContext, SkillEntry } from "./types.js";
|
||||
|
||||
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
||||
"browser.enabled": true,
|
||||
"browser.evaluateEnabled": true,
|
||||
};
|
||||
|
||||
function isTruthy(value: unknown): boolean {
|
||||
|
||||
@@ -165,12 +165,9 @@ export function buildAgentSystemPrompt(params: {
|
||||
workspaceDir?: string;
|
||||
workspaceAccess?: "none" | "ro" | "rw";
|
||||
agentWorkspaceMount?: string;
|
||||
browserControlUrl?: string;
|
||||
browserBridgeUrl?: string;
|
||||
browserNoVncUrl?: string;
|
||||
hostBrowserAllowed?: boolean;
|
||||
allowedControlUrls?: string[];
|
||||
allowedControlHosts?: string[];
|
||||
allowedControlPorts?: number[];
|
||||
elevated?: {
|
||||
allowed: boolean;
|
||||
defaultLevel: "on" | "off" | "ask" | "full";
|
||||
@@ -419,9 +416,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
: ""
|
||||
}`
|
||||
: "",
|
||||
params.sandboxInfo.browserControlUrl
|
||||
? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}`
|
||||
: "",
|
||||
params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "",
|
||||
params.sandboxInfo.browserNoVncUrl
|
||||
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
|
||||
: "",
|
||||
@@ -430,15 +425,6 @@ export function buildAgentSystemPrompt(params: {
|
||||
: params.sandboxInfo.hostBrowserAllowed === false
|
||||
? "Host browser control: blocked."
|
||||
: "",
|
||||
params.sandboxInfo.allowedControlUrls?.length
|
||||
? `Browser control URL allowlist: ${params.sandboxInfo.allowedControlUrls.join(", ")}`
|
||||
: "",
|
||||
params.sandboxInfo.allowedControlHosts?.length
|
||||
? `Browser control host allowlist: ${params.sandboxInfo.allowedControlHosts.join(", ")}`
|
||||
: "",
|
||||
params.sandboxInfo.allowedControlPorts?.length
|
||||
? `Browser control port allowlist: ${params.sandboxInfo.allowedControlPorts.join(", ")}`
|
||||
: "",
|
||||
params.sandboxInfo.elevated?.allowed
|
||||
? "Elevated exec is available for this session."
|
||||
: "",
|
||||
|
||||
@@ -35,7 +35,7 @@ const BROWSER_TOOL_ACTIONS = [
|
||||
"act",
|
||||
] as const;
|
||||
|
||||
const BROWSER_TARGETS = ["sandbox", "host", "custom", "node"] as const;
|
||||
const BROWSER_TARGETS = ["sandbox", "host", "node"] as const;
|
||||
|
||||
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
|
||||
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
|
||||
@@ -86,7 +86,6 @@ export const BrowserToolSchema = Type.Object({
|
||||
target: optionalStringEnum(BROWSER_TARGETS),
|
||||
node: Type.Optional(Type.String()),
|
||||
profile: Type.Optional(Type.String()),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
targetUrl: Type.Optional(Type.String()),
|
||||
targetId: Type.Optional(Type.String()),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
|
||||
@@ -28,23 +28,7 @@ vi.mock("../../browser/client.js", () => browserClientMocks);
|
||||
const browserConfigMocks = vi.hoisted(() => ({
|
||||
resolveBrowserConfig: vi.fn(() => ({
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 18791,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
cdpIsLoopback: true,
|
||||
color: "#FF0000",
|
||||
headless: true,
|
||||
noSandbox: false,
|
||||
attachOnly: false,
|
||||
defaultProfile: "clawd",
|
||||
profiles: {
|
||||
clawd: {
|
||||
cdpPort: 18792,
|
||||
color: "#FF0000",
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
vi.mock("../../browser/config.js", () => browserConfigMocks);
|
||||
@@ -99,7 +83,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
format: "ai",
|
||||
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||
@@ -117,7 +101,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
});
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
maxChars: override,
|
||||
}),
|
||||
@@ -141,7 +125,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.(null, { action: "profiles" });
|
||||
|
||||
expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith("http://127.0.0.1:18791");
|
||||
expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("passes refs mode through to browser snapshot", async () => {
|
||||
@@ -149,7 +133,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", refs: "aria" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
format: "ai",
|
||||
refs: "aria",
|
||||
@@ -165,7 +149,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
mode: "efficient",
|
||||
}),
|
||||
@@ -185,11 +169,11 @@ describe("browser tool snapshot maxChars", () => {
|
||||
});
|
||||
|
||||
it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => {
|
||||
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
|
||||
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
|
||||
await tool.execute?.(null, { action: "snapshot", profile: "chrome", snapshotFormat: "ai" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
profile: "chrome",
|
||||
}),
|
||||
@@ -220,7 +204,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps sandbox control url when node proxy is available", async () => {
|
||||
it("keeps sandbox bridge url when node proxy is available", async () => {
|
||||
nodesUtilsMocks.listNodes.mockResolvedValue([
|
||||
{
|
||||
nodeId: "node-1",
|
||||
@@ -230,7 +214,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
commands: ["browser.proxy"],
|
||||
},
|
||||
]);
|
||||
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
|
||||
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
|
||||
await tool.execute?.(null, { action: "status" });
|
||||
|
||||
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
||||
@@ -254,7 +238,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||
await tool.execute?.(null, { action: "status", profile: "chrome" });
|
||||
|
||||
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
undefined,
|
||||
expect.objectContaining({ profile: "chrome" }),
|
||||
);
|
||||
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
|
||||
|
||||
@@ -55,9 +55,8 @@ function isBrowserNode(node: NodeListNode) {
|
||||
|
||||
async function resolveBrowserNodeTarget(params: {
|
||||
requestedNode?: string;
|
||||
target?: "sandbox" | "host" | "custom" | "node";
|
||||
controlUrl?: string;
|
||||
defaultControlUrl?: string;
|
||||
target?: "sandbox" | "host" | "node";
|
||||
sandboxBridgeUrl?: string;
|
||||
}): Promise<BrowserNodeTarget | null> {
|
||||
const cfg = loadConfig();
|
||||
const policy = cfg.gateway?.nodes?.browser;
|
||||
@@ -68,10 +67,9 @@ async function resolveBrowserNodeTarget(params: {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (params.defaultControlUrl?.trim() && params.target !== "node" && !params.requestedNode) {
|
||||
if (params.sandboxBridgeUrl?.trim() && params.target !== "node" && !params.requestedNode) {
|
||||
return null;
|
||||
}
|
||||
if (params.controlUrl?.trim()) return null;
|
||||
if (params.target && params.target !== "node") return null;
|
||||
if (mode === "manual" && params.target !== "node" && !params.requestedNode) {
|
||||
return null;
|
||||
@@ -187,70 +185,22 @@ function applyProxyPaths(result: unknown, mapping: Map<string, string>) {
|
||||
}
|
||||
|
||||
function resolveBrowserBaseUrl(params: {
|
||||
target?: "sandbox" | "host" | "custom";
|
||||
controlUrl?: string;
|
||||
defaultControlUrl?: string;
|
||||
target?: "sandbox" | "host";
|
||||
sandboxBridgeUrl?: string;
|
||||
allowHostControl?: boolean;
|
||||
allowedControlUrls?: string[];
|
||||
allowedControlHosts?: string[];
|
||||
allowedControlPorts?: number[];
|
||||
}) {
|
||||
}): string | undefined {
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser);
|
||||
const normalizedControlUrl = params.controlUrl?.trim() ?? "";
|
||||
const normalizedDefault = params.defaultControlUrl?.trim() ?? "";
|
||||
const target =
|
||||
params.target ?? (normalizedControlUrl ? "custom" : normalizedDefault ? "sandbox" : "host");
|
||||
|
||||
const assertAllowedControlUrl = (url: string) => {
|
||||
const allowedUrls = params.allowedControlUrls?.map((entry) => entry.trim().replace(/\/$/, ""));
|
||||
const allowedHosts = params.allowedControlHosts?.map((entry) => entry.trim().toLowerCase());
|
||||
const allowedPorts = params.allowedControlPorts;
|
||||
if (!allowedUrls?.length && !allowedHosts?.length && !allowedPorts?.length) {
|
||||
return;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid browser controlUrl: ${url}`);
|
||||
}
|
||||
const normalizedUrl = parsed.toString().replace(/\/$/, "");
|
||||
if (allowedUrls?.length && !allowedUrls.includes(normalizedUrl)) {
|
||||
throw new Error("Browser controlUrl is not in the allowed URL list.");
|
||||
}
|
||||
if (allowedHosts?.length && !allowedHosts.includes(parsed.hostname)) {
|
||||
throw new Error("Browser controlUrl hostname is not in the allowed host list.");
|
||||
}
|
||||
if (allowedPorts?.length) {
|
||||
const port =
|
||||
parsed.port?.trim() !== "" ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
|
||||
if (!Number.isFinite(port) || !allowedPorts.includes(port)) {
|
||||
throw new Error("Browser controlUrl port is not in the allowed port list.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (target !== "custom" && params.target && normalizedControlUrl) {
|
||||
throw new Error('controlUrl is only supported with target="custom".');
|
||||
}
|
||||
|
||||
if (target === "custom") {
|
||||
if (!normalizedControlUrl) {
|
||||
throw new Error("Custom browser target requires controlUrl.");
|
||||
}
|
||||
const normalized = normalizedControlUrl.replace(/\/$/, "");
|
||||
assertAllowedControlUrl(normalized);
|
||||
return normalized;
|
||||
}
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const normalizedSandbox = params.sandboxBridgeUrl?.trim() ?? "";
|
||||
const target = params.target ?? (normalizedSandbox ? "sandbox" : "host");
|
||||
|
||||
if (target === "sandbox") {
|
||||
if (!normalizedDefault) {
|
||||
if (!normalizedSandbox) {
|
||||
throw new Error(
|
||||
'Sandbox browser is unavailable. Enable agents.defaults.sandbox.browser.enabled or use target="host" if allowed.',
|
||||
);
|
||||
}
|
||||
return normalizedDefault.replace(/\/$/, "");
|
||||
return normalizedSandbox.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
if (params.allowHostControl === false) {
|
||||
@@ -261,27 +211,16 @@ function resolveBrowserBaseUrl(params: {
|
||||
"Browser control is disabled. Set browser.enabled=true in ~/.clawdbot/clawdbot.json.",
|
||||
);
|
||||
}
|
||||
const normalized = resolved.controlUrl.replace(/\/$/, "");
|
||||
assertAllowedControlUrl(normalized);
|
||||
return normalized;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createBrowserTool(opts?: {
|
||||
defaultControlUrl?: string;
|
||||
sandboxBridgeUrl?: string;
|
||||
allowHostControl?: boolean;
|
||||
allowedControlUrls?: string[];
|
||||
allowedControlHosts?: string[];
|
||||
allowedControlPorts?: number[];
|
||||
}): AnyAgentTool {
|
||||
const targetDefault = opts?.defaultControlUrl ? "sandbox" : "host";
|
||||
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
|
||||
const hostHint =
|
||||
opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed.";
|
||||
const allowlistHint =
|
||||
opts?.allowedControlUrls?.length ||
|
||||
opts?.allowedControlHosts?.length ||
|
||||
opts?.allowedControlPorts?.length
|
||||
? "Custom targets are restricted by sandbox allowlists."
|
||||
: "Custom targets are unrestricted.";
|
||||
return {
|
||||
label: "Browser",
|
||||
name: "browser",
|
||||
@@ -294,33 +233,22 @@ export function createBrowserTool(opts?: {
|
||||
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
|
||||
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
|
||||
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
|
||||
`target selects browser location (sandbox|host|custom|node). Default: ${targetDefault}.`,
|
||||
"controlUrl implies target=custom (remote control server).",
|
||||
`target selects browser location (sandbox|host|node). Default: ${targetDefault}.`,
|
||||
hostHint,
|
||||
allowlistHint,
|
||||
].join(" "),
|
||||
parameters: BrowserToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const controlUrl = readStringParam(params, "controlUrl");
|
||||
const profile = readStringParam(params, "profile");
|
||||
const requestedNode = readStringParam(params, "node");
|
||||
let target = readStringParam(params, "target") as
|
||||
| "sandbox"
|
||||
| "host"
|
||||
| "custom"
|
||||
| "node"
|
||||
| undefined;
|
||||
let target = readStringParam(params, "target") as "sandbox" | "host" | "node" | undefined;
|
||||
|
||||
if (controlUrl?.trim() && (target === "node" || requestedNode)) {
|
||||
throw new Error('controlUrl is not supported with target="node".');
|
||||
}
|
||||
if (target === "custom" && requestedNode) {
|
||||
throw new Error('node is not supported with target="custom".');
|
||||
if (requestedNode && target && target !== "node") {
|
||||
throw new Error('node is only supported with target="node".');
|
||||
}
|
||||
|
||||
if (!target && !controlUrl?.trim() && !requestedNode && profile === "chrome") {
|
||||
if (!target && !requestedNode && profile === "chrome") {
|
||||
// Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node.
|
||||
target = "host";
|
||||
}
|
||||
@@ -328,21 +256,16 @@ export function createBrowserTool(opts?: {
|
||||
const nodeTarget = await resolveBrowserNodeTarget({
|
||||
requestedNode: requestedNode ?? undefined,
|
||||
target,
|
||||
controlUrl,
|
||||
defaultControlUrl: opts?.defaultControlUrl,
|
||||
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
|
||||
});
|
||||
|
||||
const resolvedTarget = target === "node" ? undefined : target;
|
||||
const baseUrl = nodeTarget
|
||||
? ""
|
||||
? undefined
|
||||
: resolveBrowserBaseUrl({
|
||||
target: resolvedTarget,
|
||||
controlUrl,
|
||||
defaultControlUrl: opts?.defaultControlUrl,
|
||||
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
|
||||
allowHostControl: opts?.allowHostControl,
|
||||
allowedControlUrls: opts?.allowedControlUrls,
|
||||
allowedControlHosts: opts?.allowedControlHosts,
|
||||
allowedControlPorts: opts?.allowedControlPorts,
|
||||
});
|
||||
|
||||
const proxyRequest = nodeTarget
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
|
||||
import {
|
||||
deleteMessageTelegram,
|
||||
editMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
sendMessageTelegram,
|
||||
} from "../../telegram/send.js";
|
||||
@@ -209,5 +210,50 @@ export async function handleTelegramAction(
|
||||
return jsonResult({ ok: true, deleted: true });
|
||||
}
|
||||
|
||||
if (action === "editMessage") {
|
||||
if (!isActionEnabled("editMessage")) {
|
||||
throw new Error("Telegram editMessage is disabled.");
|
||||
}
|
||||
const chatId = readStringOrNumberParam(params, "chatId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readNumberParam(params, "messageId", {
|
||||
required: true,
|
||||
integer: true,
|
||||
});
|
||||
const content = readStringParam(params, "content", {
|
||||
required: true,
|
||||
allowEmpty: false,
|
||||
});
|
||||
const buttons = readTelegramButtons(params);
|
||||
if (buttons) {
|
||||
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
if (inlineButtonsScope === "off") {
|
||||
throw new Error(
|
||||
'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".',
|
||||
);
|
||||
}
|
||||
}
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, {
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
buttons,
|
||||
});
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messageId: result.messageId,
|
||||
chatId: result.chatId,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Telegram action: ${action}`);
|
||||
}
|
||||
@@ -181,9 +181,44 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
defineChatCommand({
|
||||
key: "tts",
|
||||
nativeName: "tts",
|
||||
description: "Configure text-to-speech.",
|
||||
description: "Control text-to-speech (TTS).",
|
||||
textAlias: "/tts",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "action",
|
||||
description: "TTS action",
|
||||
type: "string",
|
||||
choices: [
|
||||
{ value: "on", label: "On" },
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "status", label: "Status" },
|
||||
{ value: "provider", label: "Provider" },
|
||||
{ value: "limit", label: "Limit" },
|
||||
{ value: "summary", label: "Summary" },
|
||||
{ value: "audio", label: "Audio" },
|
||||
{ value: "help", label: "Help" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "value",
|
||||
description: "Provider, limit, or text",
|
||||
type: "string",
|
||||
captureRemaining: true,
|
||||
},
|
||||
],
|
||||
argsMenu: {
|
||||
arg: "action",
|
||||
title:
|
||||
"TTS Actions:\n" +
|
||||
"• On – Enable TTS for responses\n" +
|
||||
"• Off – Disable TTS\n" +
|
||||
"• Status – Show current settings\n" +
|
||||
"• Provider – Set voice provider (edge, elevenlabs, openai)\n" +
|
||||
"• Limit – Set max characters for TTS\n" +
|
||||
"• Summary – Toggle AI summary for long texts\n" +
|
||||
"• Audio – Generate TTS from custom text\n" +
|
||||
"• Help – Show usage guide",
|
||||
},
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "whoami",
|
||||
|
||||
@@ -229,7 +229,12 @@ describe("commands registry args", () => {
|
||||
|
||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("mode");
|
||||
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
|
||||
expect(menu?.choices).toEqual([
|
||||
{ label: "off", value: "off" },
|
||||
{ label: "tokens", value: "tokens" },
|
||||
{ label: "full", value: "full" },
|
||||
{ label: "cost", value: "cost" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not show menus when arg already provided", () => {
|
||||
@@ -284,7 +289,10 @@ describe("commands registry args", () => {
|
||||
|
||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("level");
|
||||
expect(menu?.choices).toEqual(["low", "high"]);
|
||||
expect(menu?.choices).toEqual([
|
||||
{ label: "low", value: "low" },
|
||||
{ label: "high", value: "high" },
|
||||
]);
|
||||
expect(seen?.commandKey).toBe("think");
|
||||
expect(seen?.argName).toBe("level");
|
||||
expect(seen?.provider).toBeTruthy();
|
||||
|
||||
@@ -255,33 +255,41 @@ function resolveDefaultCommandContext(cfg?: ClawdbotConfig): {
|
||||
};
|
||||
}
|
||||
|
||||
export type ResolvedCommandArgChoice = { value: string; label: string };
|
||||
|
||||
export function resolveCommandArgChoices(params: {
|
||||
command: ChatCommandDefinition;
|
||||
arg: CommandArgDefinition;
|
||||
cfg?: ClawdbotConfig;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
}): string[] {
|
||||
}): ResolvedCommandArgChoice[] {
|
||||
const { command, arg, cfg } = params;
|
||||
if (!arg.choices) return [];
|
||||
const provided = arg.choices;
|
||||
if (Array.isArray(provided)) return provided;
|
||||
const defaults = resolveDefaultCommandContext(cfg);
|
||||
const context: CommandArgChoiceContext = {
|
||||
cfg,
|
||||
provider: params.provider ?? defaults.provider,
|
||||
model: params.model ?? defaults.model,
|
||||
command,
|
||||
arg,
|
||||
};
|
||||
return provided(context);
|
||||
const raw = Array.isArray(provided)
|
||||
? provided
|
||||
: (() => {
|
||||
const defaults = resolveDefaultCommandContext(cfg);
|
||||
const context: CommandArgChoiceContext = {
|
||||
cfg,
|
||||
provider: params.provider ?? defaults.provider,
|
||||
model: params.model ?? defaults.model,
|
||||
command,
|
||||
arg,
|
||||
};
|
||||
return provided(context);
|
||||
})();
|
||||
return raw.map((choice) =>
|
||||
typeof choice === "string" ? { value: choice, label: choice } : choice,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCommandArgMenu(params: {
|
||||
command: ChatCommandDefinition;
|
||||
args?: CommandArgs;
|
||||
cfg?: ClawdbotConfig;
|
||||
}): { arg: CommandArgDefinition; choices: string[]; title?: string } | null {
|
||||
}): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string } | null {
|
||||
const { command, args, cfg } = params;
|
||||
if (!command.args || !command.argsMenu) return null;
|
||||
if (command.argsParsing === "none") return null;
|
||||
|
||||
@@ -12,14 +12,16 @@ export type CommandArgChoiceContext = {
|
||||
arg: CommandArgDefinition;
|
||||
};
|
||||
|
||||
export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => string[];
|
||||
export type CommandArgChoice = string | { value: string; label: string };
|
||||
|
||||
export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => CommandArgChoice[];
|
||||
|
||||
export type CommandArgDefinition = {
|
||||
name: string;
|
||||
description: string;
|
||||
type: CommandArgType;
|
||||
required?: boolean;
|
||||
choices?: string[] | CommandArgChoicesProvider;
|
||||
choices?: CommandArgChoice[] | CommandArgChoicesProvider;
|
||||
captureRemaining?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
registerAgentRunContext(runId, {
|
||||
sessionKey: params.sessionKey,
|
||||
verboseLevel: params.resolvedVerboseLevel,
|
||||
isHeartbeat: params.isHeartbeat,
|
||||
});
|
||||
}
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
@@ -137,6 +138,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
cfg: params.followupRun.run.config,
|
||||
provider: params.followupRun.run.provider,
|
||||
model: params.followupRun.run.model,
|
||||
agentDir: params.followupRun.run.agentDir,
|
||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||
params.followupRun.run.config,
|
||||
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
||||
@@ -230,6 +232,10 @@ export async function runAgentTurnWithFallback(params: {
|
||||
groupChannel:
|
||||
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
|
||||
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
|
||||
senderId: params.sessionCtx.SenderId?.trim() || undefined,
|
||||
senderName: params.sessionCtx.SenderName?.trim() || undefined,
|
||||
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
|
||||
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
|
||||
// Provider threading context for tool auto-injection
|
||||
...buildThreadingToolContext({
|
||||
sessionCtx: params.sessionCtx,
|
||||
|
||||
@@ -92,6 +92,7 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
cfg: params.followupRun.run.config,
|
||||
provider: params.followupRun.run.provider,
|
||||
model: params.followupRun.run.model,
|
||||
agentDir: params.followupRun.run.agentDir,
|
||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||
params.followupRun.run.config,
|
||||
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
||||
@@ -114,6 +115,10 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
config: params.followupRun.run.config,
|
||||
hasRepliedRef: params.opts?.hasRepliedRef,
|
||||
}),
|
||||
senderId: params.sessionCtx.SenderId?.trim() || undefined,
|
||||
senderName: params.sessionCtx.SenderName?.trim() || undefined,
|
||||
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
|
||||
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
|
||||
sessionFile: params.followupRun.run.sessionFile,
|
||||
workspaceDir: params.followupRun.run.workspaceDir,
|
||||
agentDir: params.followupRun.run.agentDir,
|
||||
|
||||
@@ -6,20 +6,18 @@ import {
|
||||
getTtsMaxLength,
|
||||
getTtsProvider,
|
||||
isSummarizationEnabled,
|
||||
isTtsEnabled,
|
||||
isTtsProviderConfigured,
|
||||
normalizeTtsAutoMode,
|
||||
resolveTtsAutoMode,
|
||||
resolveTtsApiKey,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
resolveTtsProviderOrder,
|
||||
setLastTtsAttempt,
|
||||
setSummarizationEnabled,
|
||||
setTtsEnabled,
|
||||
setTtsMaxLength,
|
||||
setTtsProvider,
|
||||
textToSpeech,
|
||||
} from "../../tts/tts.js";
|
||||
import { updateSessionStore } from "../../config/sessions.js";
|
||||
|
||||
type ParsedTtsCommand = {
|
||||
action: string;
|
||||
@@ -40,14 +38,27 @@ function ttsUsage(): ReplyPayload {
|
||||
// Keep usage in one place so help/validation stays consistent.
|
||||
return {
|
||||
text:
|
||||
"⚙️ Usage: /tts <off|always|inbound|tagged|status|provider|limit|summary|audio> [value]" +
|
||||
"\nExamples:\n" +
|
||||
"/tts always\n" +
|
||||
"/tts provider openai\n" +
|
||||
"/tts provider edge\n" +
|
||||
"/tts limit 2000\n" +
|
||||
"/tts summary off\n" +
|
||||
"/tts audio Hello from Clawdbot",
|
||||
`🔊 **TTS (Text-to-Speech) Help**\n\n` +
|
||||
`**Commands:**\n` +
|
||||
`• /tts on — Enable automatic TTS for replies\n` +
|
||||
`• /tts off — Disable TTS\n` +
|
||||
`• /tts status — Show current settings\n` +
|
||||
`• /tts provider [name] — View/change provider\n` +
|
||||
`• /tts limit [number] — View/change text limit\n` +
|
||||
`• /tts summary [on|off] — View/change auto-summary\n` +
|
||||
`• /tts audio <text> — Generate audio from text\n\n` +
|
||||
`**Providers:**\n` +
|
||||
`• edge — Free, fast (default)\n` +
|
||||
`• openai — High quality (requires API key)\n` +
|
||||
`• elevenlabs — Premium voices (requires API key)\n\n` +
|
||||
`**Text Limit (default: 1500, max: 4096):**\n` +
|
||||
`When text exceeds the limit:\n` +
|
||||
`• Summary ON: AI summarizes, then generates audio\n` +
|
||||
`• Summary OFF: Truncates text, then generates audio\n\n` +
|
||||
`**Examples:**\n` +
|
||||
`/tts provider edge\n` +
|
||||
`/tts limit 2000\n` +
|
||||
`/tts audio Hello, this is a test!`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,35 +83,27 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
return { shouldContinue: false, reply: ttsUsage() };
|
||||
}
|
||||
|
||||
const requestedAuto = normalizeTtsAutoMode(
|
||||
action === "on" ? "always" : action === "off" ? "off" : action,
|
||||
);
|
||||
if (requestedAuto) {
|
||||
const entry = params.sessionEntry;
|
||||
const sessionKey = params.sessionKey;
|
||||
const store = params.sessionStore;
|
||||
if (entry && store && sessionKey) {
|
||||
entry.ttsAuto = requestedAuto;
|
||||
entry.updatedAt = Date.now();
|
||||
store[sessionKey] = entry;
|
||||
if (params.storePath) {
|
||||
await updateSessionStore(params.storePath, (store) => {
|
||||
store[sessionKey] = entry;
|
||||
});
|
||||
}
|
||||
}
|
||||
const label = requestedAuto === "always" ? "enabled (always)" : requestedAuto;
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: requestedAuto === "off" ? "🔇 TTS disabled." : `🔊 TTS ${label}.`,
|
||||
},
|
||||
};
|
||||
if (action === "on") {
|
||||
setTtsEnabled(prefsPath, true);
|
||||
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
|
||||
}
|
||||
|
||||
if (action === "off") {
|
||||
setTtsEnabled(prefsPath, false);
|
||||
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
|
||||
}
|
||||
|
||||
if (action === "audio") {
|
||||
if (!args.trim()) {
|
||||
return { shouldContinue: false, reply: ttsUsage() };
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text:
|
||||
`🎤 Generate audio from text.\n\n` +
|
||||
`Usage: /tts audio <text>\n` +
|
||||
`Example: /tts audio Hello, this is a test!`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
@@ -146,9 +149,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
if (action === "provider") {
|
||||
const currentProvider = getTtsProvider(config, prefsPath);
|
||||
if (!args.trim()) {
|
||||
const fallback = resolveTtsProviderOrder(currentProvider)
|
||||
.slice(1)
|
||||
.filter((provider) => isTtsProviderConfigured(config, provider));
|
||||
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
|
||||
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
|
||||
const hasEdge = isTtsProviderConfigured(config, "edge");
|
||||
@@ -158,7 +158,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
text:
|
||||
`🎙️ TTS provider\n` +
|
||||
`Primary: ${currentProvider}\n` +
|
||||
`Fallbacks: ${fallback.join(", ") || "none"}\n` +
|
||||
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
|
||||
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
|
||||
`Edge enabled: ${hasEdge ? "✅" : "❌"}\n` +
|
||||
@@ -173,18 +172,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
}
|
||||
|
||||
setTtsProvider(prefsPath, requested);
|
||||
const fallback = resolveTtsProviderOrder(requested)
|
||||
.slice(1)
|
||||
.filter((provider) => isTtsProviderConfigured(config, provider));
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text:
|
||||
`✅ TTS provider set to ${requested} (fallbacks: ${fallback.join(", ") || "none"}).` +
|
||||
(requested === "edge"
|
||||
? "\nEnable Edge TTS in config: messages.tts.edge.enabled = true."
|
||||
: ""),
|
||||
},
|
||||
reply: { text: `✅ TTS provider set to ${requested}.` },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,12 +183,22 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
const currentLimit = getTtsMaxLength(prefsPath);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `📏 TTS limit: ${currentLimit} characters.` },
|
||||
reply: {
|
||||
text:
|
||||
`📏 TTS limit: ${currentLimit} characters.\n\n` +
|
||||
`Text longer than this triggers summary (if enabled).\n` +
|
||||
`Range: 100-4096 chars (Telegram max).\n\n` +
|
||||
`To change: /tts limit <number>\n` +
|
||||
`Example: /tts limit 2000`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const next = Number.parseInt(args.trim(), 10);
|
||||
if (!Number.isFinite(next) || next < 100 || next > 10_000) {
|
||||
return { shouldContinue: false, reply: ttsUsage() };
|
||||
if (!Number.isFinite(next) || next < 100 || next > 4096) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "❌ Limit must be between 100 and 4096 characters." },
|
||||
};
|
||||
}
|
||||
setTtsMaxLength(prefsPath, next);
|
||||
return {
|
||||
@@ -210,9 +210,17 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
if (action === "summary") {
|
||||
if (!args.trim()) {
|
||||
const enabled = isSummarizationEnabled(prefsPath);
|
||||
const maxLen = getTtsMaxLength(prefsPath);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `📝 TTS auto-summary: ${enabled ? "on" : "off"}.` },
|
||||
reply: {
|
||||
text:
|
||||
`📝 TTS auto-summary: ${enabled ? "on" : "off"}.\n\n` +
|
||||
`When text exceeds ${maxLen} chars:\n` +
|
||||
`• ON: summarizes text, then generates audio\n` +
|
||||
`• OFF: truncates text, then generates audio\n\n` +
|
||||
`To change: /tts summary on | off`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const requested = args.trim().toLowerCase();
|
||||
@@ -229,27 +237,16 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
}
|
||||
|
||||
if (action === "status") {
|
||||
const sessionAuto = params.sessionEntry?.ttsAuto;
|
||||
const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto });
|
||||
const enabled = autoMode !== "off";
|
||||
const enabled = isTtsEnabled(config, prefsPath);
|
||||
const provider = getTtsProvider(config, prefsPath);
|
||||
const hasKey = isTtsProviderConfigured(config, provider);
|
||||
const providerStatus =
|
||||
provider === "edge"
|
||||
? hasKey
|
||||
? "✅ enabled"
|
||||
: "❌ disabled"
|
||||
: hasKey
|
||||
? "✅ key"
|
||||
: "❌ no key";
|
||||
const maxLength = getTtsMaxLength(prefsPath);
|
||||
const summarize = isSummarizationEnabled(prefsPath);
|
||||
const last = getLastTtsAttempt();
|
||||
const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode;
|
||||
const lines = [
|
||||
"📊 TTS status",
|
||||
`Auto: ${enabled ? autoLabel : "off"}`,
|
||||
`Provider: ${provider} (${providerStatus})`,
|
||||
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
|
||||
`Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`,
|
||||
`Text limit: ${maxLength} chars`,
|
||||
`Auto-summary: ${summarize ? "on" : "off"}`,
|
||||
];
|
||||
|
||||
@@ -420,3 +420,17 @@ describe("handleCommands subagents", () => {
|
||||
expect(result.reply?.text).toContain("Status: done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands /tts", () => {
|
||||
it("returns status for bare /tts on text command surfaces", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: { tts: { prefsPath: path.join(testWorkspaceDir, "tts.json") } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/tts", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("TTS status");
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
|
||||
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
|
||||
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||
import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js";
|
||||
import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js";
|
||||
|
||||
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
|
||||
const AUDIO_HEADER_RE = /^\[Audio\b/i;
|
||||
@@ -266,12 +266,26 @@ export async function dispatchReplyFromConfig(params: {
|
||||
return { queuedFinal, counts };
|
||||
}
|
||||
|
||||
// Track accumulated block text for TTS generation after streaming completes.
|
||||
// When block streaming succeeds, there's no final reply, so we need to generate
|
||||
// TTS audio separately from the accumulated block content.
|
||||
let accumulatedBlockText = "";
|
||||
let blockCount = 0;
|
||||
|
||||
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
|
||||
ctx,
|
||||
{
|
||||
...params.replyOptions,
|
||||
onBlockReply: (payload: ReplyPayload, context) => {
|
||||
const run = async () => {
|
||||
// Accumulate block text for TTS generation after streaming
|
||||
if (payload.text) {
|
||||
if (accumulatedBlockText.length > 0) {
|
||||
accumulatedBlockText += "\n";
|
||||
}
|
||||
accumulatedBlockText += payload.text;
|
||||
blockCount++;
|
||||
}
|
||||
const ttsPayload = await maybeApplyTtsToPayload({
|
||||
payload,
|
||||
cfg,
|
||||
@@ -327,6 +341,62 @@ export async function dispatchReplyFromConfig(params: {
|
||||
queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal;
|
||||
}
|
||||
}
|
||||
|
||||
const ttsMode = resolveTtsConfig(cfg).mode ?? "final";
|
||||
// Generate TTS-only reply after block streaming completes (when there's no final reply).
|
||||
// This handles the case where block streaming succeeds and drops final payloads,
|
||||
// but we still want TTS audio to be generated from the accumulated block content.
|
||||
if (
|
||||
ttsMode === "final" &&
|
||||
replies.length === 0 &&
|
||||
blockCount > 0 &&
|
||||
accumulatedBlockText.trim()
|
||||
) {
|
||||
try {
|
||||
const ttsSyntheticReply = await maybeApplyTtsToPayload({
|
||||
payload: { text: accumulatedBlockText },
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "final",
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
});
|
||||
// Only send if TTS was actually applied (mediaUrl exists)
|
||||
if (ttsSyntheticReply.mediaUrl) {
|
||||
// Send TTS-only payload (no text, just audio) so it doesn't duplicate the block content
|
||||
const ttsOnlyPayload: ReplyPayload = {
|
||||
mediaUrl: ttsSyntheticReply.mediaUrl,
|
||||
audioAsVoice: ttsSyntheticReply.audioAsVoice,
|
||||
};
|
||||
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
||||
const result = await routeReply({
|
||||
payload: ttsOnlyPayload,
|
||||
channel: originatingChannel,
|
||||
to: originatingTo,
|
||||
sessionKey: ctx.SessionKey,
|
||||
accountId: ctx.AccountId,
|
||||
threadId: ctx.MessageThreadId,
|
||||
cfg,
|
||||
});
|
||||
queuedFinal = result.ok || queuedFinal;
|
||||
if (result.ok) routedFinalCount += 1;
|
||||
if (!result.ok) {
|
||||
logVerbose(
|
||||
`dispatch-from-config: route-reply (tts-only) failed: ${result.error ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const didQueue = dispatcher.sendFinalReply(ttsOnlyPayload);
|
||||
queuedFinal = didQueue || queuedFinal;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`dispatch-from-config: accumulated block TTS failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await dispatcher.waitForIdle();
|
||||
|
||||
const counts = dispatcher.getQueuedCounts();
|
||||
|
||||
@@ -129,6 +129,7 @@ export function createFollowupRunner(params: {
|
||||
cfg: queued.run.config,
|
||||
provider: queued.run.provider,
|
||||
model: queued.run.model,
|
||||
agentDir: queued.run.agentDir,
|
||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||
queued.run.config,
|
||||
resolveAgentIdFromSessionKey(queued.run.sessionKey),
|
||||
@@ -146,6 +147,10 @@ export function createFollowupRunner(params: {
|
||||
groupId: queued.run.groupId,
|
||||
groupChannel: queued.run.groupChannel,
|
||||
groupSpace: queued.run.groupSpace,
|
||||
senderId: queued.run.senderId,
|
||||
senderName: queued.run.senderName,
|
||||
senderUsername: queued.run.senderUsername,
|
||||
senderE164: queued.run.senderE164,
|
||||
sessionFile: queued.run.sessionFile,
|
||||
workspaceDir: queued.run.workspaceDir,
|
||||
config: queued.run.config,
|
||||
|
||||
@@ -370,6 +370,10 @@ export async function runPreparedReply(
|
||||
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
|
||||
groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(),
|
||||
groupSpace: sessionCtx.GroupSpace?.trim() ?? undefined,
|
||||
senderId: sessionCtx.SenderId?.trim() || undefined,
|
||||
senderName: sessionCtx.SenderName?.trim() || undefined,
|
||||
senderUsername: sessionCtx.SenderUsername?.trim() || undefined,
|
||||
senderE164: sessionCtx.SenderE164?.trim() || undefined,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
|
||||
@@ -3,6 +3,26 @@ import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
|
||||
export const HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
|
||||
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
|
||||
|
||||
/** Maximum number of group history keys to retain (LRU eviction when exceeded). */
|
||||
export const MAX_HISTORY_KEYS = 1000;
|
||||
|
||||
/**
|
||||
* Evict oldest keys from a history map when it exceeds MAX_HISTORY_KEYS.
|
||||
* Uses Map's insertion order for LRU-like behavior.
|
||||
*/
|
||||
export function evictOldHistoryKeys<T>(
|
||||
historyMap: Map<string, T[]>,
|
||||
maxKeys: number = MAX_HISTORY_KEYS,
|
||||
): void {
|
||||
if (historyMap.size <= maxKeys) return;
|
||||
const keysToDelete = historyMap.size - maxKeys;
|
||||
const iterator = historyMap.keys();
|
||||
for (let i = 0; i < keysToDelete; i++) {
|
||||
const key = iterator.next().value;
|
||||
if (key !== undefined) historyMap.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export type HistoryEntry = {
|
||||
sender: string;
|
||||
body: string;
|
||||
@@ -34,7 +54,13 @@ export function appendHistoryEntry<T extends HistoryEntry>(params: {
|
||||
const history = historyMap.get(historyKey) ?? [];
|
||||
history.push(entry);
|
||||
while (history.length > params.limit) history.shift();
|
||||
if (historyMap.has(historyKey)) {
|
||||
// Refresh insertion order so eviction keeps recently used histories.
|
||||
historyMap.delete(historyKey);
|
||||
}
|
||||
historyMap.set(historyKey, history);
|
||||
// Evict oldest keys if map exceeds max size to prevent unbounded memory growth
|
||||
evictOldHistoryKeys(historyMap);
|
||||
return history;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ export type FollowupRun = {
|
||||
groupId?: string;
|
||||
groupChannel?: string;
|
||||
groupSpace?: string;
|
||||
senderId?: string;
|
||||
senderName?: string;
|
||||
senderUsername?: string;
|
||||
senderE164?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config: ClawdbotConfig;
|
||||
|
||||
@@ -21,7 +21,11 @@ export async function prependSystemEvents(params: {
|
||||
if (!trimmed) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.includes("reason periodic")) return null;
|
||||
if (lower.includes("heartbeat")) return null;
|
||||
// Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat"
|
||||
// The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this
|
||||
if (lower.startsWith("read heartbeat.md")) return null;
|
||||
// Also filter heartbeat poll/wake noise
|
||||
if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) return null;
|
||||
if (trimmed.startsWith("Node:")) {
|
||||
return trimmed.replace(/ · last input [^·]+/i, "").trim();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import express from "express";
|
||||
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
import { registerBrowserRoutes } from "./routes/index.js";
|
||||
import type { BrowserRouteRegistrar } from "./routes/types.js";
|
||||
import {
|
||||
type BrowserServerState,
|
||||
createBrowserRouteContext,
|
||||
@@ -50,7 +51,7 @@ export async function startBrowserBridgeServer(params: {
|
||||
getState: () => state,
|
||||
onEnsureAttachTarget: params.onEnsureAttachTarget,
|
||||
});
|
||||
registerBrowserRoutes(app, ctx);
|
||||
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
|
||||
|
||||
const server = await new Promise<Server>((resolve, reject) => {
|
||||
const s = app.listen(port, host, () => resolve(s));
|
||||
@@ -61,11 +62,9 @@ export async function startBrowserBridgeServer(params: {
|
||||
const resolvedPort = address?.port ?? port;
|
||||
state.server = server;
|
||||
state.port = resolvedPort;
|
||||
state.resolved.controlHost = host;
|
||||
state.resolved.controlPort = resolvedPort;
|
||||
state.resolved.controlUrl = `http://${host}:${resolvedPort}`;
|
||||
|
||||
const baseUrl = state.resolved.controlUrl;
|
||||
const baseUrl = `http://${host}:${resolvedPort}`;
|
||||
return { server, port: resolvedPort, baseUrl, state };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@ function buildProfileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
function withBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) return path;
|
||||
return `${trimmed.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
export type BrowserFormField = {
|
||||
ref: string;
|
||||
type: string;
|
||||
@@ -92,11 +98,15 @@ export type BrowserDownloadPayload = {
|
||||
};
|
||||
|
||||
export async function browserNavigate(
|
||||
baseUrl: string,
|
||||
opts: { url: string; targetId?: string; profile?: string },
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
url: string;
|
||||
targetId?: string;
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<BrowserActionTabResult> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/navigate${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(withBaseUrl(baseUrl, `/navigate${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
|
||||
@@ -105,7 +115,7 @@ export async function browserNavigate(
|
||||
}
|
||||
|
||||
export async function browserArmDialog(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
accept: boolean;
|
||||
promptText?: string;
|
||||
@@ -115,7 +125,7 @@ export async function browserArmDialog(
|
||||
},
|
||||
): Promise<BrowserActionOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/dialog${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/dialog${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -129,7 +139,7 @@ export async function browserArmDialog(
|
||||
}
|
||||
|
||||
export async function browserArmFileChooser(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
paths: string[];
|
||||
ref?: string;
|
||||
@@ -141,7 +151,7 @@ export async function browserArmFileChooser(
|
||||
},
|
||||
): Promise<BrowserActionOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/file-chooser${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/file-chooser${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -157,7 +167,7 @@ export async function browserArmFileChooser(
|
||||
}
|
||||
|
||||
export async function browserWaitForDownload(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
path?: string;
|
||||
targetId?: string;
|
||||
@@ -170,7 +180,7 @@ export async function browserWaitForDownload(
|
||||
ok: true;
|
||||
targetId: string;
|
||||
download: BrowserDownloadPayload;
|
||||
}>(`${baseUrl}/wait/download${q}`, {
|
||||
}>(withBaseUrl(baseUrl, `/wait/download${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -183,7 +193,7 @@ export async function browserWaitForDownload(
|
||||
}
|
||||
|
||||
export async function browserDownload(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
ref: string;
|
||||
path: string;
|
||||
@@ -197,7 +207,7 @@ export async function browserDownload(
|
||||
ok: true;
|
||||
targetId: string;
|
||||
download: BrowserDownloadPayload;
|
||||
}>(`${baseUrl}/download${q}`, {
|
||||
}>(withBaseUrl(baseUrl, `/download${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -211,12 +221,12 @@ export async function browserDownload(
|
||||
}
|
||||
|
||||
export async function browserAct(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
req: BrowserActRequest,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserActResponse> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act${q}`, {
|
||||
return await fetchBrowserJson<BrowserActResponse>(withBaseUrl(baseUrl, `/act${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
@@ -225,7 +235,7 @@ export async function browserAct(
|
||||
}
|
||||
|
||||
export async function browserScreenshotAction(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
targetId?: string;
|
||||
fullPage?: boolean;
|
||||
@@ -236,7 +246,7 @@ export async function browserScreenshotAction(
|
||||
},
|
||||
): Promise<BrowserActionPathResult> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/screenshot${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/screenshot${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -10,8 +10,14 @@ function buildProfileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
function withBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) return path;
|
||||
return `${trimmed.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
export async function browserConsoleMessages(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { level?: string; targetId?: string; profile?: string } = {},
|
||||
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
|
||||
const q = new URLSearchParams();
|
||||
@@ -23,15 +29,15 @@ export async function browserConsoleMessages(
|
||||
ok: true;
|
||||
messages: BrowserConsoleMessage[];
|
||||
targetId: string;
|
||||
}>(`${baseUrl}/console${suffix}`, { timeoutMs: 20000 });
|
||||
}>(withBaseUrl(baseUrl, `/console${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserPdfSave(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; profile?: string } = {},
|
||||
): Promise<BrowserActionPathResult> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/pdf${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/pdf${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId }),
|
||||
@@ -40,7 +46,7 @@ export async function browserPdfSave(
|
||||
}
|
||||
|
||||
export async function browserPageErrors(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; clear?: boolean; profile?: string } = {},
|
||||
): Promise<{ ok: true; targetId: string; errors: BrowserPageError[] }> {
|
||||
const q = new URLSearchParams();
|
||||
@@ -52,11 +58,11 @@ export async function browserPageErrors(
|
||||
ok: true;
|
||||
targetId: string;
|
||||
errors: BrowserPageError[];
|
||||
}>(`${baseUrl}/errors${suffix}`, { timeoutMs: 20000 });
|
||||
}>(withBaseUrl(baseUrl, `/errors${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserRequests(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
targetId?: string;
|
||||
filter?: string;
|
||||
@@ -74,11 +80,11 @@ export async function browserRequests(
|
||||
ok: true;
|
||||
targetId: string;
|
||||
requests: BrowserNetworkRequest[];
|
||||
}>(`${baseUrl}/requests${suffix}`, { timeoutMs: 20000 });
|
||||
}>(withBaseUrl(baseUrl, `/requests${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserTraceStart(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
targetId?: string;
|
||||
screenshots?: boolean;
|
||||
@@ -88,7 +94,7 @@ export async function browserTraceStart(
|
||||
} = {},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/trace/start${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/trace/start${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -102,11 +108,11 @@ export async function browserTraceStart(
|
||||
}
|
||||
|
||||
export async function browserTraceStop(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; path?: string; profile?: string } = {},
|
||||
): Promise<BrowserActionPathResult> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/trace/stop${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/trace/stop${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
|
||||
@@ -115,11 +121,11 @@ export async function browserTraceStop(
|
||||
}
|
||||
|
||||
export async function browserHighlight(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { ref: string; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/highlight${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/highlight${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
|
||||
@@ -128,7 +134,7 @@ export async function browserHighlight(
|
||||
}
|
||||
|
||||
export async function browserResponseBody(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
url: string;
|
||||
targetId?: string;
|
||||
@@ -158,7 +164,7 @@ export async function browserResponseBody(
|
||||
body: string;
|
||||
truncated?: boolean;
|
||||
};
|
||||
}>(`${baseUrl}/response/body${q}`, {
|
||||
}>(withBaseUrl(baseUrl, `/response/body${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -5,8 +5,14 @@ function buildProfileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
function withBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) return path;
|
||||
return `${trimmed.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
export async function browserCookies(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; profile?: string } = {},
|
||||
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
|
||||
const q = new URLSearchParams();
|
||||
@@ -17,11 +23,11 @@ export async function browserCookies(
|
||||
ok: true;
|
||||
targetId: string;
|
||||
cookies: unknown[];
|
||||
}>(`${baseUrl}/cookies${suffix}`, { timeoutMs: 20000 });
|
||||
}>(withBaseUrl(baseUrl, `/cookies${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserCookiesSet(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
cookie: Record<string, unknown>;
|
||||
targetId?: string;
|
||||
@@ -29,7 +35,7 @@ export async function browserCookiesSet(
|
||||
},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/set${q}`, {
|
||||
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 }),
|
||||
@@ -38,11 +44,11 @@ export async function browserCookiesSet(
|
||||
}
|
||||
|
||||
export async function browserCookiesClear(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; profile?: string } = {},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/clear${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/clear${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId }),
|
||||
@@ -51,7 +57,7 @@ export async function browserCookiesClear(
|
||||
}
|
||||
|
||||
export async function browserStorageGet(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
kind: "local" | "session";
|
||||
key?: string;
|
||||
@@ -68,11 +74,11 @@ export async function browserStorageGet(
|
||||
ok: true;
|
||||
targetId: string;
|
||||
values: Record<string, string>;
|
||||
}>(`${baseUrl}/storage/${opts.kind}${suffix}`, { timeoutMs: 20000 });
|
||||
}>(withBaseUrl(baseUrl, `/storage/${opts.kind}${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserStorageSet(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
kind: "local" | "session";
|
||||
key: string;
|
||||
@@ -82,25 +88,28 @@ export async function browserStorageSet(
|
||||
},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${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 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserStorageClear(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||
`${baseUrl}/storage/${opts.kind}/clear${q}`,
|
||||
withBaseUrl(baseUrl, `/storage/${opts.kind}/clear${q}`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -111,11 +120,11 @@ export async function browserStorageClear(
|
||||
}
|
||||
|
||||
export async function browserSetOffline(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { offline: boolean; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/offline${q}`, {
|
||||
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 }),
|
||||
@@ -124,7 +133,7 @@ export async function browserSetOffline(
|
||||
}
|
||||
|
||||
export async function browserSetHeaders(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
headers: Record<string, string>;
|
||||
targetId?: string;
|
||||
@@ -132,7 +141,7 @@ export async function browserSetHeaders(
|
||||
},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/headers${q}`, {
|
||||
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 }),
|
||||
@@ -141,7 +150,7 @@ export async function browserSetHeaders(
|
||||
}
|
||||
|
||||
export async function browserSetHttpCredentials(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
@@ -151,21 +160,24 @@ export async function browserSetHttpCredentials(
|
||||
} = {},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${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 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserSetGeolocation(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
@@ -177,23 +189,26 @@ export async function browserSetGeolocation(
|
||||
} = {},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${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 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserSetMedia(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
colorScheme: "dark" | "light" | "no-preference" | "none";
|
||||
targetId?: string;
|
||||
@@ -201,7 +216,7 @@ export async function browserSetMedia(
|
||||
},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/media${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/media${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -213,11 +228,11 @@ export async function browserSetMedia(
|
||||
}
|
||||
|
||||
export async function browserSetTimezone(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { timezoneId: string; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/timezone${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/timezone${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -229,11 +244,11 @@ export async function browserSetTimezone(
|
||||
}
|
||||
|
||||
export async function browserSetLocale(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { locale: string; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/locale${q}`, {
|
||||
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 }),
|
||||
@@ -242,11 +257,11 @@ export async function browserSetLocale(
|
||||
}
|
||||
|
||||
export async function browserSetDevice(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { name: string; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/device${q}`, {
|
||||
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 }),
|
||||
@@ -255,11 +270,11 @@ export async function browserSetDevice(
|
||||
}
|
||||
|
||||
export async function browserClearPermissions(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; profile?: string } = {},
|
||||
): Promise<BrowserActionOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/set/geolocation${q}`, {
|
||||
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/set/geolocation${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
|
||||
|
||||
+87
-67
@@ -1,57 +1,51 @@
|
||||
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveBrowserConfig } from "./config.js";
|
||||
import {
|
||||
createBrowserControlContext,
|
||||
startBrowserControlServiceFromConfig,
|
||||
} from "./control-service.js";
|
||||
import { createBrowserRouteDispatcher } from "./routes/dispatcher.js";
|
||||
|
||||
let cachedConfigToken: string | null | undefined = undefined;
|
||||
|
||||
function getBrowserControlToken(): string | null {
|
||||
const env = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
|
||||
if (env) return env;
|
||||
|
||||
if (cachedConfigToken !== undefined) return cachedConfigToken;
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser);
|
||||
const token = resolved.controlToken?.trim() || "";
|
||||
cachedConfigToken = token ? token : null;
|
||||
} catch {
|
||||
cachedConfigToken = null;
|
||||
}
|
||||
return cachedConfigToken;
|
||||
}
|
||||
|
||||
function unwrapCause(err: unknown): unknown {
|
||||
if (!err || typeof err !== "object") return null;
|
||||
const cause = (err as { cause?: unknown }).cause;
|
||||
return cause ?? null;
|
||||
function isAbsoluteHttp(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url.trim());
|
||||
}
|
||||
|
||||
function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
|
||||
const cause = unwrapCause(err);
|
||||
const code = extractErrorCode(cause) ?? extractErrorCode(err) ?? "";
|
||||
|
||||
const hint = `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
|
||||
|
||||
if (code === "ECONNREFUSED") {
|
||||
const hint = isAbsoluteHttp(url)
|
||||
? "If this is a sandboxed session, ensure the sandbox browser is running and try again."
|
||||
: `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
|
||||
const msg = String(err);
|
||||
const msgLower = msg.toLowerCase();
|
||||
const looksLikeTimeout =
|
||||
msgLower.includes("timed out") ||
|
||||
msgLower.includes("timeout") ||
|
||||
msgLower.includes("aborted") ||
|
||||
msgLower.includes("abort") ||
|
||||
msgLower.includes("aborterror");
|
||||
if (looksLikeTimeout) {
|
||||
return new Error(
|
||||
`Can't reach the clawd browser control server at ${url} (connection refused). ${hint}`,
|
||||
);
|
||||
}
|
||||
if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
return new Error(
|
||||
`Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,
|
||||
`Can't reach the clawd browser control service (timed out after ${timeoutMs}ms). ${hint}`,
|
||||
);
|
||||
}
|
||||
return new Error(`Can't reach the clawd browser control service. ${hint} (${msg})`);
|
||||
}
|
||||
|
||||
const msg = formatErrorMessage(err);
|
||||
if (msg.toLowerCase().includes("abort")) {
|
||||
return new Error(
|
||||
`Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,
|
||||
);
|
||||
async function fetchHttpJson<T>(
|
||||
url: string,
|
||||
init: RequestInit & { timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
const timeoutMs = init.timeoutMs ?? 5000;
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
|
||||
return new Error(`Can't reach the clawd browser control server at ${url}. ${hint} (${msg})`);
|
||||
}
|
||||
|
||||
export async function fetchBrowserJson<T>(
|
||||
@@ -59,32 +53,58 @@ export async function fetchBrowserJson<T>(
|
||||
init?: RequestInit & { timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
const timeoutMs = init?.timeoutMs ?? 5000;
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
let res: Response;
|
||||
try {
|
||||
const token = getBrowserControlToken();
|
||||
const mergedHeaders = (() => {
|
||||
if (!token) return init?.headers;
|
||||
const h = new Headers(init?.headers ?? {});
|
||||
if (!h.has("Authorization")) {
|
||||
h.set("Authorization", `Bearer ${token}`);
|
||||
if (isAbsoluteHttp(url)) {
|
||||
return await fetchHttpJson<T>(url, { ...init, timeoutMs });
|
||||
}
|
||||
const started = await startBrowserControlServiceFromConfig();
|
||||
if (!started) {
|
||||
throw new Error("browser control disabled");
|
||||
}
|
||||
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
||||
const parsed = new URL(url, "http://localhost");
|
||||
const query: Record<string, unknown> = {};
|
||||
for (const [key, value] of parsed.searchParams.entries()) {
|
||||
query[key] = value;
|
||||
}
|
||||
let body = init?.body;
|
||||
if (typeof body === "string") {
|
||||
try {
|
||||
body = JSON.parse(body);
|
||||
} catch {
|
||||
// keep as string
|
||||
}
|
||||
return h;
|
||||
})();
|
||||
res = await fetch(url, {
|
||||
...init,
|
||||
...(mergedHeaders ? { headers: mergedHeaders } : {}),
|
||||
signal: ctrl.signal,
|
||||
} as RequestInit);
|
||||
}
|
||||
const dispatchPromise = dispatcher.dispatch({
|
||||
method:
|
||||
init?.method?.toUpperCase() === "DELETE"
|
||||
? "DELETE"
|
||||
: init?.method?.toUpperCase() === "POST"
|
||||
? "POST"
|
||||
: "GET",
|
||||
path: parsed.pathname,
|
||||
query,
|
||||
body,
|
||||
});
|
||||
|
||||
const result = await (timeoutMs
|
||||
? Promise.race([
|
||||
dispatchPromise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("timed out")), timeoutMs),
|
||||
),
|
||||
])
|
||||
: dispatchPromise);
|
||||
|
||||
if (result.status >= 400) {
|
||||
const message =
|
||||
result.body && typeof result.body === "object" && "error" in result.body
|
||||
? String((result.body as { error?: unknown }).error)
|
||||
: `HTTP ${result.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return result.body as T;
|
||||
} catch (err) {
|
||||
throw enhanceBrowserFetchError(url, err, timeoutMs);
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ describe("browser client", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("wraps connection failures with a gateway hint", async () => {
|
||||
it("wraps connection failures with a sandbox hint", async () => {
|
||||
const refused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1"), {
|
||||
code: "ECONNREFUSED",
|
||||
});
|
||||
@@ -26,7 +26,7 @@ describe("browser client", () => {
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(fetchFailed));
|
||||
|
||||
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/Start .*gateway/i);
|
||||
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/sandboxed session/i);
|
||||
});
|
||||
|
||||
it("adds useful timeout messaging for abort-like failures", async () => {
|
||||
@@ -34,41 +34,6 @@ describe("browser client", () => {
|
||||
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/timed out/i);
|
||||
});
|
||||
|
||||
it("adds Authorization when CLAWDBOT_BROWSER_CONTROL_TOKEN is set", async () => {
|
||||
const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = "t1";
|
||||
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
running: false,
|
||||
pid: null,
|
||||
cdpPort: 18792,
|
||||
chosenBrowser: null,
|
||||
userDataDir: null,
|
||||
color: "#FF0000",
|
||||
headless: true,
|
||||
attachOnly: false,
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
await browserStatus("http://127.0.0.1:18791");
|
||||
const init = calls[0]?.init;
|
||||
const auth = new Headers(init?.headers ?? {}).get("Authorization");
|
||||
expect(auth).toBe("Bearer t1");
|
||||
|
||||
process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev;
|
||||
});
|
||||
|
||||
it("surfaces non-2xx responses with body text", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -81,7 +46,7 @@ describe("browser client", () => {
|
||||
|
||||
await expect(
|
||||
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
|
||||
).rejects.toThrow(/409: conflict/i);
|
||||
).rejects.toThrow(/conflict/i);
|
||||
});
|
||||
|
||||
it("adds labels + efficient mode query params to snapshots", async () => {
|
||||
@@ -255,7 +220,6 @@ describe("browser client", () => {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
running: true,
|
||||
pid: 1,
|
||||
cdpPort: 18792,
|
||||
|
||||
+56
-51
@@ -1,10 +1,7 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { fetchBrowserJson } from "./client-fetch.js";
|
||||
import { resolveBrowserConfig } from "./config.js";
|
||||
|
||||
export type BrowserStatus = {
|
||||
enabled: boolean;
|
||||
controlUrl: string;
|
||||
profile?: string;
|
||||
running: boolean;
|
||||
cdpReady?: boolean;
|
||||
@@ -89,59 +86,64 @@ export type SnapshotResult =
|
||||
imageType?: "png" | "jpeg";
|
||||
};
|
||||
|
||||
export function resolveBrowserControlUrl(overrideUrl?: string) {
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser);
|
||||
const url = overrideUrl?.trim() ? overrideUrl.trim() : resolved.controlUrl;
|
||||
return url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function buildProfileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
function withBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) return path;
|
||||
return `${trimmed.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
export async function browserStatus(
|
||||
baseUrl: string,
|
||||
baseUrl?: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserStatus> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserStatus>(`${baseUrl}/${q}`, {
|
||||
return await fetchBrowserJson<BrowserStatus>(withBaseUrl(baseUrl, `/${q}`), {
|
||||
timeoutMs: 1500,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserProfiles(baseUrl: string): Promise<ProfileStatus[]> {
|
||||
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(`${baseUrl}/profiles`, {
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
export async function browserProfiles(baseUrl?: string): Promise<ProfileStatus[]> {
|
||||
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
|
||||
withBaseUrl(baseUrl, `/profiles`),
|
||||
{
|
||||
timeoutMs: 3000,
|
||||
},
|
||||
);
|
||||
return res.profiles ?? [];
|
||||
}
|
||||
|
||||
export async function browserStart(baseUrl: string, opts?: { profile?: string }): Promise<void> {
|
||||
export async function browserStart(baseUrl?: string, opts?: { profile?: string }): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(`${baseUrl}/start${q}`, {
|
||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/start${q}`), {
|
||||
method: "POST",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserStop(baseUrl: string, opts?: { profile?: string }): Promise<void> {
|
||||
export async function browserStop(baseUrl?: string, opts?: { profile?: string }): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(`${baseUrl}/stop${q}`, {
|
||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/stop${q}`), {
|
||||
method: "POST",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserResetProfile(
|
||||
baseUrl: string,
|
||||
baseUrl?: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserResetProfileResult> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserResetProfileResult>(`${baseUrl}/reset-profile${q}`, {
|
||||
method: "POST",
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
return await fetchBrowserJson<BrowserResetProfileResult>(
|
||||
withBaseUrl(baseUrl, `/reset-profile${q}`),
|
||||
{
|
||||
method: "POST",
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type BrowserCreateProfileResult = {
|
||||
@@ -154,7 +156,7 @@ export type BrowserCreateProfileResult = {
|
||||
};
|
||||
|
||||
export async function browserCreateProfile(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
name: string;
|
||||
color?: string;
|
||||
@@ -162,17 +164,20 @@ export async function browserCreateProfile(
|
||||
driver?: "clawd" | "extension";
|
||||
},
|
||||
): Promise<BrowserCreateProfileResult> {
|
||||
return await fetchBrowserJson<BrowserCreateProfileResult>(`${baseUrl}/profiles/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: opts.name,
|
||||
color: opts.color,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
driver: opts.driver,
|
||||
}),
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
return await fetchBrowserJson<BrowserCreateProfileResult>(
|
||||
withBaseUrl(baseUrl, `/profiles/create`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: opts.name,
|
||||
color: opts.color,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
driver: opts.driver,
|
||||
}),
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type BrowserDeleteProfileResult = {
|
||||
@@ -182,11 +187,11 @@ export type BrowserDeleteProfileResult = {
|
||||
};
|
||||
|
||||
export async function browserDeleteProfile(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
profile: string,
|
||||
): Promise<BrowserDeleteProfileResult> {
|
||||
return await fetchBrowserJson<BrowserDeleteProfileResult>(
|
||||
`${baseUrl}/profiles/${encodeURIComponent(profile)}`,
|
||||
withBaseUrl(baseUrl, `/profiles/${encodeURIComponent(profile)}`),
|
||||
{
|
||||
method: "DELETE",
|
||||
timeoutMs: 20000,
|
||||
@@ -195,24 +200,24 @@ export async function browserDeleteProfile(
|
||||
}
|
||||
|
||||
export async function browserTabs(
|
||||
baseUrl: string,
|
||||
baseUrl?: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserTab[]> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
|
||||
`${baseUrl}/tabs${q}`,
|
||||
withBaseUrl(baseUrl, `/tabs${q}`),
|
||||
{ timeoutMs: 3000 },
|
||||
);
|
||||
return res.tabs ?? [];
|
||||
}
|
||||
|
||||
export async function browserOpenTab(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
url: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserTab> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserTab>(`${baseUrl}/tabs/open${q}`, {
|
||||
return await fetchBrowserJson<BrowserTab>(withBaseUrl(baseUrl, `/tabs/open${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
@@ -221,12 +226,12 @@ export async function browserOpenTab(
|
||||
}
|
||||
|
||||
export async function browserFocusTab(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
targetId: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(`${baseUrl}/tabs/focus${q}`, {
|
||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/focus${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId }),
|
||||
@@ -235,19 +240,19 @@ export async function browserFocusTab(
|
||||
}
|
||||
|
||||
export async function browserCloseTab(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
targetId: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(`${baseUrl}/tabs/${encodeURIComponent(targetId)}${q}`, {
|
||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/${encodeURIComponent(targetId)}${q}`), {
|
||||
method: "DELETE",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserTabAction(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
action: "list" | "new" | "close" | "select";
|
||||
index?: number;
|
||||
@@ -255,7 +260,7 @@ export async function browserTabAction(
|
||||
},
|
||||
): Promise<unknown> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson(`${baseUrl}/tabs/action${q}`, {
|
||||
return await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/action${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -267,7 +272,7 @@ export async function browserTabAction(
|
||||
}
|
||||
|
||||
export async function browserSnapshot(
|
||||
baseUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
format: "aria" | "ai";
|
||||
targetId?: string;
|
||||
@@ -301,7 +306,7 @@ export async function browserSnapshot(
|
||||
if (opts.labels === true) q.set("labels", "1");
|
||||
if (opts.mode) q.set("mode", opts.mode);
|
||||
if (opts.profile) q.set("profile", opts.profile);
|
||||
return await fetchBrowserJson<SnapshotResult>(`${baseUrl}/snapshot?${q.toString()}`, {
|
||||
return await fetchBrowserJson<SnapshotResult>(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), {
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
+31
-24
@@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
|
||||
|
||||
describe("browser config", () => {
|
||||
it("defaults to enabled with loopback control url and lobster-orange color", () => {
|
||||
it("defaults to enabled with loopback defaults and lobster-orange color", () => {
|
||||
const resolved = resolveBrowserConfig(undefined);
|
||||
expect(resolved.enabled).toBe(true);
|
||||
expect(resolved.controlPort).toBe(18791);
|
||||
expect(resolved.controlHost).toBe("127.0.0.1");
|
||||
expect(resolved.color).toBe("#FF4500");
|
||||
expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
|
||||
expect(resolved.cdpHost).toBe("127.0.0.1");
|
||||
expect(resolved.cdpProtocol).toBe("http");
|
||||
const profile = resolveProfile(resolved, resolved.defaultProfile);
|
||||
expect(profile?.name).toBe("chrome");
|
||||
expect(profile?.driver).toBe("extension");
|
||||
@@ -46,9 +47,31 @@ describe("browser config", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("derives default ports from gateway.port when env is unset", () => {
|
||||
const prev = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
try {
|
||||
const resolved = resolveBrowserConfig(undefined, { gateway: { port: 19011 } });
|
||||
expect(resolved.controlPort).toBe(19013);
|
||||
const chrome = resolveProfile(resolved, "chrome");
|
||||
expect(chrome?.driver).toBe("extension");
|
||||
expect(chrome?.cdpPort).toBe(19014);
|
||||
expect(chrome?.cdpUrl).toBe("http://127.0.0.1:19014");
|
||||
|
||||
const clawd = resolveProfile(resolved, "clawd");
|
||||
expect(clawd?.cdpPort).toBe(19022);
|
||||
expect(clawd?.cdpUrl).toBe("http://127.0.0.1:19022");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes hex colors", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://localhost:18791",
|
||||
color: "ff4500",
|
||||
});
|
||||
expect(resolved.color).toBe("#FF4500");
|
||||
@@ -56,7 +79,6 @@ describe("browser config", () => {
|
||||
|
||||
it("supports custom remote CDP timeouts", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
remoteCdpTimeoutMs: 2200,
|
||||
remoteCdpHandshakeTimeoutMs: 5000,
|
||||
});
|
||||
@@ -66,31 +88,21 @@ describe("browser config", () => {
|
||||
|
||||
it("falls back to default color for invalid hex", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://localhost:18791",
|
||||
color: "#GGGGGG",
|
||||
});
|
||||
expect(resolved.color).toBe("#FF4500");
|
||||
});
|
||||
|
||||
it("treats non-loopback control urls as remote", () => {
|
||||
it("treats non-loopback cdpUrl as remote", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://example.com:18791",
|
||||
cdpUrl: "http://example.com:9222",
|
||||
});
|
||||
expect(shouldStartLocalBrowserServer(resolved)).toBe(false);
|
||||
});
|
||||
|
||||
it("derives CDP host/protocol from control url when cdpUrl is unset", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:19000",
|
||||
});
|
||||
expect(resolved.controlPort).toBe(19000);
|
||||
expect(resolved.cdpHost).toBe("127.0.0.1");
|
||||
expect(resolved.cdpProtocol).toBe("http");
|
||||
const profile = resolveProfile(resolved, "clawd");
|
||||
expect(profile?.cdpIsLoopback).toBe(false);
|
||||
});
|
||||
|
||||
it("supports explicit CDP URLs for the default profile", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
cdpUrl: "http://example.com:9222",
|
||||
});
|
||||
const profile = resolveProfile(resolved, "clawd");
|
||||
@@ -101,7 +113,6 @@ describe("browser config", () => {
|
||||
|
||||
it("uses profile cdpUrl when provided", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
profiles: {
|
||||
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
|
||||
},
|
||||
@@ -115,7 +126,6 @@ describe("browser config", () => {
|
||||
|
||||
it("uses base protocol for profiles with only cdpPort", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
cdpUrl: "https://example.com:9443",
|
||||
profiles: {
|
||||
work: { cdpPort: 18801, color: "#0066CC" },
|
||||
@@ -127,14 +137,11 @@ describe("browser config", () => {
|
||||
});
|
||||
|
||||
it("rejects unsupported protocols", () => {
|
||||
expect(() => resolveBrowserConfig({ controlUrl: "ws://127.0.0.1:18791" })).toThrow(
|
||||
/must be http/i,
|
||||
);
|
||||
expect(() => resolveBrowserConfig({ cdpUrl: "ws://127.0.0.1:18791" })).toThrow(/must be http/i);
|
||||
});
|
||||
|
||||
it("does not add the built-in chrome extension profile if the derived relay port is already used", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
profiles: {
|
||||
clawd: { cdpPort: 18792, color: "#FF4500" },
|
||||
},
|
||||
|
||||
+17
-30
@@ -1,12 +1,14 @@
|
||||
import type { BrowserConfig, BrowserProfileConfig } from "../config/config.js";
|
||||
import type { BrowserConfig, BrowserProfileConfig, ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
deriveDefaultBrowserCdpPortRange,
|
||||
deriveDefaultBrowserControlPort,
|
||||
DEFAULT_BROWSER_CONTROL_PORT,
|
||||
} from "../config/port-defaults.js";
|
||||
import { resolveGatewayPort } from "../config/paths.js";
|
||||
import {
|
||||
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||
DEFAULT_CLAWD_BROWSER_CONTROL_URL,
|
||||
DEFAULT_CLAWD_BROWSER_ENABLED,
|
||||
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
|
||||
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
||||
} from "./constants.js";
|
||||
@@ -14,10 +16,8 @@ import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js";
|
||||
|
||||
export type ResolvedBrowserConfig = {
|
||||
enabled: boolean;
|
||||
controlUrl: string;
|
||||
controlHost: string;
|
||||
evaluateEnabled: boolean;
|
||||
controlPort: number;
|
||||
controlToken?: string;
|
||||
cdpProtocol: "http" | "https";
|
||||
cdpHost: string;
|
||||
cdpIsLoopback: boolean;
|
||||
@@ -137,24 +137,14 @@ function ensureDefaultChromeExtensionProfile(
|
||||
};
|
||||
return result;
|
||||
}
|
||||
export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBrowserConfig {
|
||||
export function resolveBrowserConfig(
|
||||
cfg: BrowserConfig | undefined,
|
||||
rootConfig?: ClawdbotConfig,
|
||||
): ResolvedBrowserConfig {
|
||||
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
|
||||
const envControlUrl = process.env.CLAWDBOT_BROWSER_CONTROL_URL?.trim();
|
||||
const controlToken = cfg?.controlToken?.trim() || undefined;
|
||||
const derivedControlPort = (() => {
|
||||
const raw = process.env.CLAWDBOT_GATEWAY_PORT?.trim();
|
||||
if (!raw) return null;
|
||||
const gatewayPort = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(gatewayPort) || gatewayPort <= 0) return null;
|
||||
return deriveDefaultBrowserControlPort(gatewayPort);
|
||||
})();
|
||||
const derivedControlUrl = derivedControlPort ? `http://127.0.0.1:${derivedControlPort}` : null;
|
||||
|
||||
const controlInfo = parseHttpUrl(
|
||||
cfg?.controlUrl ?? envControlUrl ?? derivedControlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL,
|
||||
"browser.controlUrl",
|
||||
);
|
||||
const controlPort = controlInfo.port;
|
||||
const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
|
||||
const gatewayPort = resolveGatewayPort(rootConfig);
|
||||
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
|
||||
const defaultColor = normalizeHexColor(cfg?.color);
|
||||
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
|
||||
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
|
||||
@@ -178,11 +168,10 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr
|
||||
const derivedPort = controlPort + 1;
|
||||
if (derivedPort > 65535) {
|
||||
throw new Error(
|
||||
`browser.controlUrl port (${controlPort}) is too high; cannot derive CDP port (${derivedPort})`,
|
||||
`Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`,
|
||||
);
|
||||
}
|
||||
const derived = new URL(controlInfo.normalized);
|
||||
derived.port = String(derivedPort);
|
||||
const derived = new URL(`http://127.0.0.1:${derivedPort}`);
|
||||
cdpInfo = {
|
||||
parsed: derived,
|
||||
port: derivedPort,
|
||||
@@ -211,10 +200,8 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr
|
||||
|
||||
return {
|
||||
enabled,
|
||||
controlUrl: controlInfo.normalized,
|
||||
controlHost: controlInfo.parsed.hostname,
|
||||
evaluateEnabled,
|
||||
controlPort,
|
||||
...(controlToken ? { controlToken } : {}),
|
||||
cdpProtocol,
|
||||
cdpHost: cdpInfo.parsed.hostname,
|
||||
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
||||
@@ -269,6 +256,6 @@ export function resolveProfile(
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldStartLocalBrowserServer(resolved: ResolvedBrowserConfig) {
|
||||
return isLoopbackHost(resolved.controlHost);
|
||||
export function shouldStartLocalBrowserServer(_resolved: ResolvedBrowserConfig) {
|
||||
return true;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export const DEFAULT_CLAWD_BROWSER_ENABLED = true;
|
||||
export const DEFAULT_CLAWD_BROWSER_CONTROL_URL = "http://127.0.0.1:18791";
|
||||
export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
|
||||
export const DEFAULT_CLAWD_BROWSER_COLOR = "#FF4500";
|
||||
export const DEFAULT_CLAWD_BROWSER_PROFILE_NAME = "clawd";
|
||||
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "chrome";
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
|
||||
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
|
||||
|
||||
let state: BrowserServerState | null = null;
|
||||
const log = createSubsystemLogger("browser");
|
||||
const logService = log.child("service");
|
||||
|
||||
export function getBrowserControlState(): BrowserServerState | null {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function createBrowserControlContext() {
|
||||
return createBrowserRouteContext({
|
||||
getState: () => state,
|
||||
});
|
||||
}
|
||||
|
||||
export async function startBrowserControlServiceFromConfig(): Promise<BrowserServerState | null> {
|
||||
if (state) return state;
|
||||
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
if (!resolved.enabled) return null;
|
||||
|
||||
state = {
|
||||
server: null,
|
||||
port: resolved.controlPort,
|
||||
resolved,
|
||||
profiles: new Map(),
|
||||
};
|
||||
|
||||
// If any profile uses the Chrome extension relay, start the local relay server eagerly
|
||||
// so the extension can connect before the first browser action.
|
||||
for (const name of Object.keys(resolved.profiles)) {
|
||||
const profile = resolveProfile(resolved, name);
|
||||
if (!profile || profile.driver !== "extension") continue;
|
||||
await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => {
|
||||
logService.warn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
logService.info(
|
||||
`Browser control service ready (profiles=${Object.keys(resolved.profiles).length})`,
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function stopBrowserControlService(): Promise<void> {
|
||||
const current = state;
|
||||
if (!current) return;
|
||||
|
||||
const ctx = createBrowserRouteContext({
|
||||
getState: () => state,
|
||||
});
|
||||
|
||||
try {
|
||||
for (const name of Object.keys(current.resolved.profiles)) {
|
||||
try {
|
||||
await ctx.forProfile(name).stopRunningBrowser();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logService.warn(`clawd browser stop failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
state = null;
|
||||
|
||||
// Optional: Playwright is not always available (e.g. embedded gateway builds).
|
||||
try {
|
||||
const mod = await import("./pw-ai.js");
|
||||
await mod.closePlaywrightBrowserConnection();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,7 @@ function createCtx(resolved: BrowserServerState["resolved"]) {
|
||||
|
||||
describe("BrowserProfilesService", () => {
|
||||
it("allocates next local port for new profiles", async () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
});
|
||||
const resolved = resolveBrowserConfig({});
|
||||
const { ctx, state } = createCtx(resolved);
|
||||
|
||||
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
||||
@@ -66,9 +64,7 @@ describe("BrowserProfilesService", () => {
|
||||
});
|
||||
|
||||
it("accepts per-profile cdpUrl for remote Chrome", async () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
});
|
||||
const resolved = resolveBrowserConfig({});
|
||||
const { ctx } = createCtx(resolved);
|
||||
|
||||
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
||||
@@ -97,7 +93,6 @@ describe("BrowserProfilesService", () => {
|
||||
|
||||
it("deletes remote profiles without stopping or removing local data", async () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
profiles: {
|
||||
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
|
||||
},
|
||||
@@ -124,7 +119,6 @@ describe("BrowserProfilesService", () => {
|
||||
|
||||
it("deletes local profiles and moves data to Trash", async () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
profiles: {
|
||||
work: { cdpPort: 18801, color: "#0066CC" },
|
||||
},
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserFormField } from "../client-actions-core.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import {
|
||||
@@ -16,8 +14,12 @@ import {
|
||||
SELECTOR_UNSUPPORTED_MESSAGE,
|
||||
} from "./agent.shared.js";
|
||||
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserAgentActRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserAgentActRoutes(
|
||||
app: BrowserRouteRegistrar,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.post("/act", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
@@ -37,6 +39,7 @@ export function registerBrowserAgentActRoutes(app: express.Express, ctx: Browser
|
||||
const cdpUrl = profileCtx.profile.cdpUrl;
|
||||
const pw = await requirePwAi(res, `act:${kind}`);
|
||||
if (!pw) return;
|
||||
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
|
||||
|
||||
switch (kind) {
|
||||
case "click": {
|
||||
@@ -208,6 +211,16 @@ export function registerBrowserAgentActRoutes(app: express.Express, ctx: Browser
|
||||
: undefined;
|
||||
const fn = toStringOrEmpty(body.fn) || undefined;
|
||||
const timeoutMs = toNumber(body.timeoutMs) ?? undefined;
|
||||
if (fn && !evaluateEnabled) {
|
||||
return jsonError(
|
||||
res,
|
||||
403,
|
||||
[
|
||||
"wait --fn is disabled by config (browser.evaluateEnabled=false).",
|
||||
"Docs: /gateway/configuration#browser-clawd-managed-browser",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
if (
|
||||
timeMs === undefined &&
|
||||
!text &&
|
||||
@@ -238,6 +251,16 @@ export function registerBrowserAgentActRoutes(app: express.Express, ctx: Browser
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
}
|
||||
case "evaluate": {
|
||||
if (!evaluateEnabled) {
|
||||
return jsonError(
|
||||
res,
|
||||
403,
|
||||
[
|
||||
"act:evaluate is disabled by config (browser.evaluateEnabled=false).",
|
||||
"Docs: /gateway/configuration#browser-clawd-managed-browser",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
const fn = toStringOrEmpty(body.fn);
|
||||
if (!fn) return jsonError(res, 400, "fn is required");
|
||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||
|
||||
@@ -2,13 +2,15 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
|
||||
import { toBoolean, toStringOrEmpty } from "./utils.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserAgentDebugRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserAgentDebugRoutes(
|
||||
app: BrowserRouteRegistrar,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.get("/console", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
import type { PwAiModule } from "../pw-ai-module.js";
|
||||
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
|
||||
import { getProfileContext, jsonError } from "./utils.js";
|
||||
import type { BrowserRequest, BrowserResponse } from "./types.js";
|
||||
|
||||
export const SELECTOR_UNSUPPORTED_MESSAGE = [
|
||||
"Error: 'selector' is not supported. Use 'ref' from snapshot instead.",
|
||||
@@ -15,21 +14,21 @@ export const SELECTOR_UNSUPPORTED_MESSAGE = [
|
||||
"This is more reliable for modern SPAs.",
|
||||
].join("\n");
|
||||
|
||||
export function readBody(req: express.Request): Record<string, unknown> {
|
||||
export function readBody(req: BrowserRequest): Record<string, unknown> {
|
||||
const body = req.body as Record<string, unknown> | undefined;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
|
||||
return body;
|
||||
}
|
||||
|
||||
export function handleRouteError(ctx: BrowserRouteContext, res: express.Response, err: unknown) {
|
||||
export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse, err: unknown) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
|
||||
export function resolveProfileContext(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
req: BrowserRequest,
|
||||
res: BrowserResponse,
|
||||
ctx: BrowserRouteContext,
|
||||
): ProfileContext | null {
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
@@ -45,7 +44,7 @@ export async function getPwAiModule(): Promise<PwAiModule | null> {
|
||||
}
|
||||
|
||||
export async function requirePwAi(
|
||||
res: express.Response,
|
||||
res: BrowserResponse,
|
||||
feature: string,
|
||||
): Promise<PwAiModule | null> {
|
||||
const mod = await getPwAiModule();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type express from "express";
|
||||
|
||||
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
||||
import { captureScreenshot, snapshotAria } from "../cdp.js";
|
||||
import {
|
||||
@@ -23,8 +21,12 @@ import {
|
||||
resolveProfileContext,
|
||||
} from "./agent.shared.js";
|
||||
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserAgentSnapshotRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserAgentSnapshotRoutes(
|
||||
app: BrowserRouteRegistrar,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.post("/navigate", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
|
||||
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserAgentStorageRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserAgentStorageRoutes(
|
||||
app: BrowserRouteRegistrar,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.get("/cookies", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { registerBrowserAgentActRoutes } from "./agent.act.js";
|
||||
import { registerBrowserAgentDebugRoutes } from "./agent.debug.js";
|
||||
import { registerBrowserAgentSnapshotRoutes } from "./agent.snapshot.js";
|
||||
import { registerBrowserAgentStorageRoutes } from "./agent.storage.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserAgentRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserAgentRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
||||
registerBrowserAgentSnapshotRoutes(app, ctx);
|
||||
registerBrowserAgentActRoutes(app, ctx);
|
||||
registerBrowserAgentDebugRoutes(app, ctx);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type express from "express";
|
||||
|
||||
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
|
||||
import { createBrowserProfilesService } from "../profiles-service.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
||||
// List all profiles with their status
|
||||
app.get("/profiles", async (_req, res) => {
|
||||
try {
|
||||
@@ -53,7 +52,6 @@ export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRou
|
||||
|
||||
res.json({
|
||||
enabled: current.resolved.enabled,
|
||||
controlUrl: current.resolved.controlUrl,
|
||||
profile: profileCtx.profile.name,
|
||||
running: cdpReady,
|
||||
cdpReady,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { registerBrowserRoutes } from "./index.js";
|
||||
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
type BrowserDispatchRequest = {
|
||||
method: "GET" | "POST" | "DELETE";
|
||||
path: string;
|
||||
query?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
type BrowserDispatchResponse = {
|
||||
status: number;
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
type RouteEntry = {
|
||||
method: BrowserDispatchRequest["method"];
|
||||
path: string;
|
||||
regex: RegExp;
|
||||
paramNames: string[];
|
||||
handler: (req: BrowserRequest, res: BrowserResponse) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function escapeRegex(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function compileRoute(path: string): { regex: RegExp; paramNames: string[] } {
|
||||
const paramNames: string[] = [];
|
||||
const parts = path.split("/").map((part) => {
|
||||
if (part.startsWith(":")) {
|
||||
const name = part.slice(1);
|
||||
paramNames.push(name);
|
||||
return "([^/]+)";
|
||||
}
|
||||
return escapeRegex(part);
|
||||
});
|
||||
return { regex: new RegExp(`^${parts.join("/")}$`), paramNames };
|
||||
}
|
||||
|
||||
function createRegistry() {
|
||||
const routes: RouteEntry[] = [];
|
||||
const register =
|
||||
(method: RouteEntry["method"]) => (path: string, handler: RouteEntry["handler"]) => {
|
||||
const { regex, paramNames } = compileRoute(path);
|
||||
routes.push({ method, path, regex, paramNames, handler });
|
||||
};
|
||||
const router: BrowserRouteRegistrar = {
|
||||
get: register("GET"),
|
||||
post: register("POST"),
|
||||
delete: register("DELETE"),
|
||||
};
|
||||
return { routes, router };
|
||||
}
|
||||
|
||||
function normalizePath(path: string) {
|
||||
if (!path) return "/";
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
|
||||
export function createBrowserRouteDispatcher(ctx: BrowserRouteContext) {
|
||||
const registry = createRegistry();
|
||||
registerBrowserRoutes(registry.router, ctx);
|
||||
|
||||
return {
|
||||
dispatch: async (req: BrowserDispatchRequest): Promise<BrowserDispatchResponse> => {
|
||||
const method = req.method;
|
||||
const path = normalizePath(req.path);
|
||||
const query = req.query ?? {};
|
||||
const body = req.body;
|
||||
|
||||
const match = registry.routes.find((route) => {
|
||||
if (route.method !== method) return false;
|
||||
return route.regex.test(path);
|
||||
});
|
||||
if (!match) {
|
||||
return { status: 404, body: { error: "Not Found" } };
|
||||
}
|
||||
|
||||
const exec = match.regex.exec(path);
|
||||
const params: Record<string, string> = {};
|
||||
if (exec) {
|
||||
for (const [idx, name] of match.paramNames.entries()) {
|
||||
const value = exec[idx + 1];
|
||||
if (typeof value === "string") {
|
||||
params[name] = decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = 200;
|
||||
let payload: unknown = undefined;
|
||||
const res: BrowserResponse = {
|
||||
status(code) {
|
||||
status = code;
|
||||
return res;
|
||||
},
|
||||
json(bodyValue) {
|
||||
payload = bodyValue;
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await match.handler(
|
||||
{
|
||||
params,
|
||||
query,
|
||||
body,
|
||||
},
|
||||
res,
|
||||
);
|
||||
} catch (err) {
|
||||
return { status: 500, body: { error: String(err) } };
|
||||
}
|
||||
|
||||
return { status, body: payload };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type { BrowserDispatchRequest, BrowserDispatchResponse };
|
||||
@@ -1,11 +1,10 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { registerBrowserAgentRoutes } from "./agent.js";
|
||||
import { registerBrowserBasicRoutes } from "./basic.js";
|
||||
import { registerBrowserTabRoutes } from "./tabs.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
||||
registerBrowserBasicRoutes(app, ctx);
|
||||
registerBrowserTabRoutes(app, ctx);
|
||||
registerBrowserAgentRoutes(app, ctx);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserTabRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||
export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
||||
app.get("/tabs", async (req, res) => {
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export type BrowserRequest = {
|
||||
params: Record<string, string>;
|
||||
query: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export type BrowserResponse = {
|
||||
status: (code: number) => BrowserResponse;
|
||||
json: (body: unknown) => void;
|
||||
};
|
||||
|
||||
export type BrowserRouteHandler = (
|
||||
req: BrowserRequest,
|
||||
res: BrowserResponse,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type BrowserRouteRegistrar = {
|
||||
get: (path: string, handler: BrowserRouteHandler) => void;
|
||||
post: (path: string, handler: BrowserRouteHandler) => void;
|
||||
delete: (path: string, handler: BrowserRouteHandler) => void;
|
||||
};
|
||||
@@ -1,14 +1,13 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
import { parseBooleanValue } from "../../utils/boolean.js";
|
||||
import type { BrowserRequest, BrowserResponse } from "./types.js";
|
||||
|
||||
/**
|
||||
* Extract profile name from query string or body and get profile context.
|
||||
* Query string takes precedence over body for consistency with GET routes.
|
||||
*/
|
||||
export function getProfileContext(
|
||||
req: express.Request,
|
||||
req: BrowserRequest,
|
||||
ctx: BrowserRouteContext,
|
||||
): ProfileContext | { error: string; status: number } {
|
||||
let profileName: string | undefined;
|
||||
@@ -33,7 +32,7 @@ export function getProfileContext(
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonError(res: express.Response, status: number, message: string) {
|
||||
export function jsonError(res: BrowserResponse, status: number, message: string) {
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,6 @@ describe("browser server-context ensureTabAvailable", () => {
|
||||
port: 0,
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 18791,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
@@ -121,8 +119,6 @@ describe("browser server-context ensureTabAvailable", () => {
|
||||
port: 0,
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 18791,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
@@ -170,8 +166,6 @@ describe("browser server-context ensureTabAvailable", () => {
|
||||
port: 0,
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 18791,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
|
||||
@@ -21,8 +21,6 @@ function makeState(
|
||||
port: 0,
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 18791,
|
||||
cdpProtocol: profile === "remote" ? "https" : "http",
|
||||
cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1",
|
||||
|
||||
@@ -17,7 +17,7 @@ export type ProfileRuntimeState = {
|
||||
};
|
||||
|
||||
export type BrowserServerState = {
|
||||
server: Server;
|
||||
server?: Server | null;
|
||||
port: number;
|
||||
resolved: ResolvedBrowserConfig;
|
||||
profiles: Map<string, ProfileRuntimeState>;
|
||||
|
||||
@@ -7,7 +7,9 @@ let testPort = 0;
|
||||
let cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let cfgEvaluateEnabled = true;
|
||||
let createTargetId: string | null = null;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
@@ -88,7 +90,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
evaluateEnabled: cfgEvaluateEnabled,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
@@ -185,6 +187,7 @@ describe("browser control server", () => {
|
||||
beforeEach(async () => {
|
||||
reachable = false;
|
||||
cfgAttachOnly = false;
|
||||
cfgEvaluateEnabled = true;
|
||||
createTargetId = null;
|
||||
|
||||
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
|
||||
@@ -197,6 +200,8 @@ describe("browser control server", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
@@ -248,6 +253,11 @@ describe("browser control server", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
@@ -342,6 +352,30 @@ describe("browser control server", () => {
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"blocks act:evaluate when browser.evaluateEnabled=false",
|
||||
async () => {
|
||||
cfgEvaluateEnabled = false;
|
||||
const base = await startServerAndBase();
|
||||
|
||||
const waitRes = (await postJson(`${base}/act`, {
|
||||
kind: "wait",
|
||||
fn: "() => window.ready === true",
|
||||
})) as { error?: string };
|
||||
expect(waitRes.error).toContain("browser.evaluateEnabled=false");
|
||||
expect(pwMocks.waitForViaPlaywright).not.toHaveBeenCalled();
|
||||
|
||||
const res = (await postJson(`${base}/act`, {
|
||||
kind: "evaluate",
|
||||
fn: "() => 1",
|
||||
})) as { error?: string };
|
||||
|
||||
expect(res.error).toContain("browser.evaluateEnabled=false");
|
||||
expect(pwMocks.evaluateViaPlaywright).not.toHaveBeenCalled();
|
||||
},
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
it("agent contract: hooks + response + downloads + screenshot", async () => {
|
||||
const base = await startServerAndBase();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ let cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
@@ -89,7 +90,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
@@ -198,6 +198,8 @@ describe("browser control server", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
@@ -249,6 +251,11 @@ describe("browser control server", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ let _cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
@@ -197,6 +197,8 @@ describe("browser control server", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
@@ -248,6 +250,11 @@ describe("browser control server", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
@@ -301,6 +308,11 @@ describe("backward compatibility (profile parameter)", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -337,6 +349,11 @@ describe("backward compatibility (profile parameter)", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ let _cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
@@ -197,6 +197,8 @@ describe("browser control server", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
@@ -248,6 +250,11 @@ describe("browser control server", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
@@ -278,6 +285,11 @@ describe("profile CRUD endpoints", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -292,6 +304,11 @@ describe("profile CRUD endpoints", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ let _cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
@@ -197,6 +197,8 @@ describe("browser control server", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
@@ -248,6 +250,11 @@ describe("browser control server", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ let cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
@@ -88,7 +89,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
@@ -197,6 +197,8 @@ describe("browser control server", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
@@ -248,6 +250,11 @@ describe("browser control server", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
@@ -394,8 +401,6 @@ describe("browser control server", () => {
|
||||
const bridge = await startBrowserBridgeServer({
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:0",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 0,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
|
||||
+9
-13
@@ -3,9 +3,10 @@ import express from "express";
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
|
||||
import { registerBrowserRoutes } from "./routes/index.js";
|
||||
import type { BrowserRouteRegistrar } from "./routes/types.js";
|
||||
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
|
||||
|
||||
let state: BrowserServerState | null = null;
|
||||
@@ -16,23 +17,16 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
||||
if (state) return state;
|
||||
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser);
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
if (!resolved.enabled) return null;
|
||||
|
||||
if (!shouldStartLocalBrowserServer(resolved)) {
|
||||
logServer.info(
|
||||
`browser control URL is non-loopback (${resolved.controlUrl}); skipping local server start`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
const ctx = createBrowserRouteContext({
|
||||
getState: () => state,
|
||||
});
|
||||
registerBrowserRoutes(app, ctx);
|
||||
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
|
||||
|
||||
const port = resolved.controlPort;
|
||||
const server = await new Promise<Server>((resolve, reject) => {
|
||||
@@ -89,9 +83,11 @@ export async function stopBrowserControlServer(): Promise<void> {
|
||||
logServer.warn(`clawd browser stop failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
current.server.close(() => resolve());
|
||||
});
|
||||
if (current.server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
current.server?.close(() => resolve());
|
||||
});
|
||||
}
|
||||
state = null;
|
||||
|
||||
// Optional: Playwright is not always available (e.g. embedded gateway builds).
|
||||
|
||||
@@ -1 +1 @@
|
||||
a99455ba0c4d0aad0a110bf25440c208b798198d5524b269f0f2d3f984262ae4
|
||||
2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a
|
||||
File diff suppressed because it is too large.
Load diff
+30
-20
@@ -8,6 +8,7 @@ import type { Duplex } from "node:stream";
|
||||
import chokidar from "chokidar";
|
||||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js";
|
||||
import { detectMime } from "../media/mime.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { ensureDir, resolveUserPath } from "../utils.js";
|
||||
@@ -145,30 +146,31 @@ async function resolveFilePath(rootReal: string, urlPath: string) {
|
||||
const rel = normalized.replace(/^\/+/, "");
|
||||
if (rel.split("/").some((p) => p === "..")) return null;
|
||||
|
||||
let candidate = path.join(rootReal, rel);
|
||||
const tryOpen = async (relative: string) => {
|
||||
try {
|
||||
return await openFileWithinRoot({ rootDir: rootReal, relativePath: relative });
|
||||
} catch (err) {
|
||||
if (err instanceof SafeOpenError) return null;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (normalized.endsWith("/")) {
|
||||
candidate = path.join(candidate, "index.html");
|
||||
return await tryOpen(path.posix.join(rel, "index.html"));
|
||||
}
|
||||
|
||||
const candidate = path.join(rootReal, rel);
|
||||
try {
|
||||
const st = await fs.stat(candidate);
|
||||
const st = await fs.lstat(candidate);
|
||||
if (st.isSymbolicLink()) return null;
|
||||
if (st.isDirectory()) {
|
||||
candidate = path.join(candidate, "index.html");
|
||||
return await tryOpen(path.posix.join(rel, "index.html"));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const rootPrefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`;
|
||||
try {
|
||||
const lstat = await fs.lstat(candidate);
|
||||
if (lstat.isSymbolicLink()) return null;
|
||||
const real = await fs.realpath(candidate);
|
||||
if (!real.startsWith(rootPrefix)) return null;
|
||||
return real;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return await tryOpen(rel);
|
||||
}
|
||||
|
||||
function isDisabledByEnv() {
|
||||
@@ -311,8 +313,8 @@ export async function createCanvasHostHandler(
|
||||
return true;
|
||||
}
|
||||
|
||||
const filePath = await resolveFilePath(rootReal, urlPath);
|
||||
if (!filePath) {
|
||||
const opened = await resolveFilePath(rootReal, urlPath);
|
||||
if (!opened) {
|
||||
if (urlPath === "/" || urlPath.endsWith("/")) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
@@ -327,22 +329,30 @@ export async function createCanvasHostHandler(
|
||||
return true;
|
||||
}
|
||||
|
||||
const lower = filePath.toLowerCase();
|
||||
const { handle, realPath } = opened;
|
||||
let data: Buffer;
|
||||
try {
|
||||
data = await handle.readFile();
|
||||
} finally {
|
||||
await handle.close().catch(() => {});
|
||||
}
|
||||
|
||||
const lower = realPath.toLowerCase();
|
||||
const mime =
|
||||
lower.endsWith(".html") || lower.endsWith(".htm")
|
||||
? "text/html"
|
||||
: ((await detectMime({ filePath })) ?? "application/octet-stream");
|
||||
: ((await detectMime({ filePath: realPath })) ?? "application/octet-stream");
|
||||
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
if (mime === "text/html") {
|
||||
const html = await fs.readFile(filePath, "utf8");
|
||||
const html = data.toString("utf8");
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(liveReload ? injectCanvasLiveReload(html) : html);
|
||||
return true;
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", mime);
|
||||
res.end(await fs.readFile(filePath));
|
||||
res.end(data);
|
||||
return true;
|
||||
} catch (err) {
|
||||
opts.runtime.error(`canvasHost request failed: ${String(err)}`);
|
||||
|
||||
@@ -62,4 +62,53 @@ describe("telegramMessageActions", () => {
|
||||
cfg,
|
||||
);
|
||||
});
|
||||
|
||||
it("maps edit action params into editMessage", async () => {
|
||||
handleTelegramAction.mockClear();
|
||||
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
|
||||
await telegramMessageActions.handleAction({
|
||||
action: "edit",
|
||||
params: {
|
||||
chatId: "123",
|
||||
messageId: 42,
|
||||
message: "Updated",
|
||||
buttons: [],
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
});
|
||||
|
||||
expect(handleTelegramAction).toHaveBeenCalledWith(
|
||||
{
|
||||
action: "editMessage",
|
||||
chatId: "123",
|
||||
messageId: 42,
|
||||
content: "Updated",
|
||||
buttons: [],
|
||||
accountId: undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-integer messageId for edit before reaching telegram-actions", async () => {
|
||||
handleTelegramAction.mockClear();
|
||||
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
|
||||
await expect(
|
||||
telegramMessageActions.handleAction({
|
||||
action: "edit",
|
||||
params: {
|
||||
chatId: "123",
|
||||
messageId: "nope",
|
||||
message: "Updated",
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(handleTelegramAction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
createActionGate,
|
||||
readNumberParam,
|
||||
readStringOrNumberParam,
|
||||
readStringParam,
|
||||
} from "../../../agents/tools/common.js";
|
||||
@@ -43,6 +44,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||
if (gate("reactions")) actions.add("react");
|
||||
if (gate("deleteMessage")) actions.add("delete");
|
||||
if (gate("editMessage")) actions.add("edit");
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsButtons: ({ cfg }) => {
|
||||
@@ -100,14 +102,39 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
readStringOrNumberParam(params, "chatId") ??
|
||||
readStringOrNumberParam(params, "channelId") ??
|
||||
readStringParam(params, "to", { required: true });
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
const messageId = readNumberParam(params, "messageId", {
|
||||
required: true,
|
||||
integer: true,
|
||||
});
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "deleteMessage",
|
||||
chatId,
|
||||
messageId: Number(messageId),
|
||||
messageId,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
const chatId =
|
||||
readStringOrNumberParam(params, "chatId") ??
|
||||
readStringOrNumberParam(params, "channelId") ??
|
||||
readStringParam(params, "to", { required: true });
|
||||
const messageId = readNumberParam(params, "messageId", {
|
||||
required: true,
|
||||
integer: true,
|
||||
});
|
||||
const message = readStringParam(params, "message", { required: true, allowEmpty: false });
|
||||
const buttons = params.buttons;
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "editMessage",
|
||||
chatId,
|
||||
messageId,
|
||||
content: message,
|
||||
buttons,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
|
||||
@@ -2,9 +2,13 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveChannelGroupRequireMention,
|
||||
resolveChannelGroupToolsPolicy,
|
||||
resolveToolsBySender,
|
||||
} from "../../config/group-policy.js";
|
||||
import type { DiscordConfig } from "../../config/types.js";
|
||||
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
||||
import type {
|
||||
GroupToolPolicyBySenderConfig,
|
||||
GroupToolPolicyConfig,
|
||||
} from "../../config/types.tools.js";
|
||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
|
||||
type GroupMentionParams = {
|
||||
@@ -13,6 +17,10 @@ type GroupMentionParams = {
|
||||
groupChannel?: string | null;
|
||||
groupSpace?: string | null;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
};
|
||||
|
||||
function normalizeDiscordSlug(value?: string | null) {
|
||||
@@ -172,6 +180,10 @@ export function resolveGoogleChatGroupToolPolicy(
|
||||
channel: "googlechat",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,6 +238,10 @@ export function resolveTelegramGroupToolPolicy(
|
||||
channel: "telegram",
|
||||
groupId: chatId ?? params.groupId,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -237,6 +253,10 @@ export function resolveWhatsAppGroupToolPolicy(
|
||||
channel: "whatsapp",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,6 +268,10 @@ export function resolveIMessageGroupToolPolicy(
|
||||
channel: "imessage",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -268,8 +292,24 @@ export function resolveDiscordGroupToolPolicy(
|
||||
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
|
||||
: undefined) ??
|
||||
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined);
|
||||
const senderPolicy = resolveToolsBySender({
|
||||
toolsBySender: entry?.toolsBySender,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
if (senderPolicy) return senderPolicy;
|
||||
if (entry?.tools) return entry.tools;
|
||||
}
|
||||
const guildSenderPolicy = resolveToolsBySender({
|
||||
toolsBySender: guildEntry?.toolsBySender,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
if (guildSenderPolicy) return guildSenderPolicy;
|
||||
if (guildEntry?.tools) return guildEntry.tools;
|
||||
return undefined;
|
||||
}
|
||||
@@ -294,7 +334,9 @@ export function resolveSlackGroupToolPolicy(
|
||||
channelName ?? "",
|
||||
normalizedName,
|
||||
].filter(Boolean);
|
||||
let matched: { tools?: GroupToolPolicyConfig } | undefined;
|
||||
let matched:
|
||||
| { tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig }
|
||||
| undefined;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && channels[candidate]) {
|
||||
matched = channels[candidate];
|
||||
@@ -302,6 +344,14 @@ export function resolveSlackGroupToolPolicy(
|
||||
}
|
||||
}
|
||||
const resolved = matched ?? channels["*"];
|
||||
const senderPolicy = resolveToolsBySender({
|
||||
toolsBySender: resolved?.toolsBySender,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
if (senderPolicy) return senderPolicy;
|
||||
if (resolved?.tools) return resolved.tools;
|
||||
return undefined;
|
||||
}
|
||||
@@ -314,5 +364,9 @@ export function resolveBlueBubblesGroupToolPolicy(
|
||||
channel: "bluebubbles",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
}
|
||||
@@ -80,14 +80,20 @@ async function promptTelegramAllowFrom(params: {
|
||||
if (!token) return null;
|
||||
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
|
||||
const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
|
||||
const res = await fetch(url);
|
||||
const data = (await res.json().catch(() => null)) as {
|
||||
ok?: boolean;
|
||||
result?: { id?: number | string };
|
||||
} | null;
|
||||
const id = data?.ok ? data?.result?.id : undefined;
|
||||
if (typeof id === "number" || typeof id === "string") return String(id);
|
||||
return null;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json().catch(() => null)) as {
|
||||
ok?: boolean;
|
||||
result?: { id?: number | string };
|
||||
} | null;
|
||||
const id = data?.ok ? data?.result?.id : undefined;
|
||||
if (typeof id === "number" || typeof id === "string") return String(id);
|
||||
return null;
|
||||
} catch {
|
||||
// Network error during username lookup - return null to prompt user for numeric ID
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseInput = (value: string) =>
|
||||
|
||||
@@ -155,6 +155,10 @@ export type ChannelGroupContext = {
|
||||
groupChannel?: string | null;
|
||||
groupSpace?: string | null;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
};
|
||||
|
||||
export type ChannelCapabilities = {
|
||||
|
||||
@@ -78,6 +78,48 @@ describe("argv helpers", () => {
|
||||
});
|
||||
expect(nodeArgv).toEqual(["node", "clawdbot", "status"]);
|
||||
|
||||
const versionedNodeArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["node-22", "clawdbot", "status"],
|
||||
});
|
||||
expect(versionedNodeArgv).toEqual(["node-22", "clawdbot", "status"]);
|
||||
|
||||
const versionedNodeWindowsArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["node-22.2.0.exe", "clawdbot", "status"],
|
||||
});
|
||||
expect(versionedNodeWindowsArgv).toEqual(["node-22.2.0.exe", "clawdbot", "status"]);
|
||||
|
||||
const versionedNodePatchlessArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["node-22.2", "clawdbot", "status"],
|
||||
});
|
||||
expect(versionedNodePatchlessArgv).toEqual(["node-22.2", "clawdbot", "status"]);
|
||||
|
||||
const versionedNodeWindowsPatchlessArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["node-22.2.exe", "clawdbot", "status"],
|
||||
});
|
||||
expect(versionedNodeWindowsPatchlessArgv).toEqual(["node-22.2.exe", "clawdbot", "status"]);
|
||||
|
||||
const versionedNodeWithPathArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["/usr/bin/node-22.2.0", "clawdbot", "status"],
|
||||
});
|
||||
expect(versionedNodeWithPathArgv).toEqual(["/usr/bin/node-22.2.0", "clawdbot", "status"]);
|
||||
|
||||
const nodejsArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["nodejs", "clawdbot", "status"],
|
||||
});
|
||||
expect(nodejsArgv).toEqual(["nodejs", "clawdbot", "status"]);
|
||||
|
||||
const nonVersionedNodeArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["node-dev", "clawdbot", "status"],
|
||||
});
|
||||
expect(nonVersionedNodeArgv).toEqual(["node", "clawdbot", "node-dev", "clawdbot", "status"]);
|
||||
|
||||
const directArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["clawdbot", "status"],
|
||||
|
||||
+17
-5
@@ -96,15 +96,27 @@ export function buildParseArgv(params: {
|
||||
: baseArgv;
|
||||
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
|
||||
const looksLikeNode =
|
||||
normalizedArgv.length >= 2 &&
|
||||
(executable === "node" ||
|
||||
executable === "node.exe" ||
|
||||
executable === "bun" ||
|
||||
executable === "bun.exe");
|
||||
normalizedArgv.length >= 2 && (isNodeExecutable(executable) || isBunExecutable(executable));
|
||||
if (looksLikeNode) return normalizedArgv;
|
||||
return ["node", programName || "clawdbot", ...normalizedArgv];
|
||||
}
|
||||
|
||||
const nodeExecutablePattern = /^node-\d+(?:\.\d+)*(?:\.exe)?$/;
|
||||
|
||||
function isNodeExecutable(executable: string): boolean {
|
||||
return (
|
||||
executable === "node" ||
|
||||
executable === "node.exe" ||
|
||||
executable === "nodejs" ||
|
||||
executable === "nodejs.exe" ||
|
||||
nodeExecutablePattern.test(executable)
|
||||
);
|
||||
}
|
||||
|
||||
function isBunExecutable(executable: string): boolean {
|
||||
return executable === "bun" || executable === "bun.exe";
|
||||
}
|
||||
|
||||
export function shouldMigrateStateFromPath(path: string[]): boolean {
|
||||
if (path.length === 0) return true;
|
||||
const [primary, secondary] = path;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Command } from "commander";
|
||||
import { browserAct } from "../../browser/client-actions.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { requireRef, resolveBrowserActionContext } from "./shared.js";
|
||||
import { callBrowserAct, requireRef, resolveBrowserActionContext } from "./shared.js";
|
||||
|
||||
export function registerBrowserElementCommands(
|
||||
browser: Command,
|
||||
@@ -18,7 +17,7 @@ export function registerBrowserElementCommands(
|
||||
.option("--button <left|right|middle>", "Mouse button to use")
|
||||
.option("--modifiers <list>", "Comma-separated modifiers (Shift,Alt,Meta)")
|
||||
.action(async (ref: string | undefined, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const refValue = requireRef(ref);
|
||||
if (!refValue) return;
|
||||
const modifiers = opts.modifiers
|
||||
@@ -28,9 +27,10 @@ export function registerBrowserElementCommands(
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct<{ url?: string }>({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "click",
|
||||
ref: refValue,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
@@ -38,8 +38,7 @@ export function registerBrowserElementCommands(
|
||||
button: opts.button?.trim() || undefined,
|
||||
modifiers,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -61,13 +60,14 @@ export function registerBrowserElementCommands(
|
||||
.option("--slowly", "Type slowly (human-like)", false)
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string | undefined, text: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const refValue = requireRef(ref);
|
||||
if (!refValue) return;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "type",
|
||||
ref: refValue,
|
||||
text,
|
||||
@@ -75,8 +75,7 @@ export function registerBrowserElementCommands(
|
||||
slowly: Boolean(opts.slowly),
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -94,13 +93,13 @@ export function registerBrowserElementCommands(
|
||||
.argument("<key>", "Key to press (e.g. Enter)")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (key: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{ kind: "press", key, targetId: opts.targetId?.trim() || undefined },
|
||||
{ profile },
|
||||
);
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: { kind: "press", key, targetId: opts.targetId?.trim() || undefined },
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -118,13 +117,13 @@ export function registerBrowserElementCommands(
|
||||
.argument("<ref>", "Ref id from snapshot")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{ kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
|
||||
{ profile },
|
||||
);
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: { kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -145,20 +144,21 @@ export function registerBrowserElementCommands(
|
||||
Number(v),
|
||||
)
|
||||
.action(async (ref: string | undefined, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const refValue = requireRef(ref);
|
||||
if (!refValue) return;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "scrollIntoView",
|
||||
ref: refValue,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -177,18 +177,18 @@ export function registerBrowserElementCommands(
|
||||
.argument("<endRef>", "End ref id")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (startRef: string, endRef: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "drag",
|
||||
startRef,
|
||||
endRef,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -207,18 +207,18 @@ export function registerBrowserElementCommands(
|
||||
.argument("<values...>", "Option values to select")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string, values: string[], opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "select",
|
||||
ref,
|
||||
values,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
browserArmDialog,
|
||||
browserArmFileChooser,
|
||||
browserDownload,
|
||||
browserWaitForDownload,
|
||||
} from "../../browser/client-actions.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { resolveBrowserActionContext } from "./shared.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
|
||||
@@ -29,17 +23,26 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (paths: string[], opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserArmFileChooser(baseUrl, {
|
||||
paths,
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
inputRef: opts.inputRef?.trim() || undefined,
|
||||
element: opts.element?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
profile,
|
||||
});
|
||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||
const result = await callBrowserRequest<{ download: { path: string } }>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
paths,
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
inputRef: opts.inputRef?.trim() || undefined,
|
||||
element: opts.element?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: timeoutMs ?? 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -62,14 +65,23 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (outPath: string | undefined, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserWaitForDownload(baseUrl, {
|
||||
path: outPath?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
profile,
|
||||
});
|
||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||
const result = await callBrowserRequest<{ download: { path: string } }>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/wait/download",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
path: outPath?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: timeoutMs ?? 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -93,15 +105,24 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (ref: string, outPath: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserDownload(baseUrl, {
|
||||
ref,
|
||||
path: outPath,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
profile,
|
||||
});
|
||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||
const result = await callBrowserRequest<{ download: { path: string } }>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/download",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
ref,
|
||||
path: outPath,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: timeoutMs ?? 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -126,7 +147,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const accept = opts.accept ? true : opts.dismiss ? false : undefined;
|
||||
if (accept === undefined) {
|
||||
defaultRuntime.error(danger("Specify --accept or --dismiss"));
|
||||
@@ -134,13 +155,22 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserArmDialog(baseUrl, {
|
||||
accept,
|
||||
promptText: opts.prompt?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
profile,
|
||||
});
|
||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/hooks/dialog",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
accept,
|
||||
promptText: opts.prompt?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: timeoutMs ?? 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Command } from "commander";
|
||||
import { browserAct } from "../../browser/client-actions.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { readFields, resolveBrowserActionContext } from "./shared.js";
|
||||
import { callBrowserAct, readFields, resolveBrowserActionContext } from "./shared.js";
|
||||
|
||||
export function registerBrowserFormWaitEvalCommands(
|
||||
browser: Command,
|
||||
@@ -16,21 +15,21 @@ export function registerBrowserFormWaitEvalCommands(
|
||||
.option("--fields-file <path>", "Read JSON array from a file")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const fields = await readFields({
|
||||
fields: opts.fields,
|
||||
fieldsFile: opts.fieldsFile,
|
||||
});
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct<{ result?: unknown }>({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "fill",
|
||||
fields,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -59,16 +58,18 @@ export function registerBrowserFormWaitEvalCommands(
|
||||
)
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (selector: string | undefined, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const sel = selector?.trim() || undefined;
|
||||
const load =
|
||||
opts.load === "load" || opts.load === "domcontentloaded" || opts.load === "networkidle"
|
||||
? (opts.load as "load" | "domcontentloaded" | "networkidle")
|
||||
: undefined;
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||
const result = await callBrowserAct<{ result?: unknown }>({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "wait",
|
||||
timeMs: Number.isFinite(opts.time) ? opts.time : undefined,
|
||||
text: opts.text?.trim() || undefined,
|
||||
@@ -78,10 +79,10 @@ export function registerBrowserFormWaitEvalCommands(
|
||||
loadState: load,
|
||||
fn: opts.fn?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||
timeoutMs,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
timeoutMs,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -100,23 +101,23 @@ export function registerBrowserFormWaitEvalCommands(
|
||||
.option("--ref <id>", "Ref from snapshot")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
if (!opts.fn) {
|
||||
defaultRuntime.error(danger("Missing --fn"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
const result = await callBrowserAct<{ result?: unknown }>({
|
||||
parent,
|
||||
profile,
|
||||
body: {
|
||||
kind: "evaluate",
|
||||
fn: opts.fn,
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { browserAct, browserNavigate } from "../../browser/client-actions.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { requireRef, resolveBrowserActionContext } from "./shared.js";
|
||||
|
||||
export function registerBrowserNavigationCommands(
|
||||
@@ -15,13 +14,21 @@ export function registerBrowserNavigationCommands(
|
||||
.argument("<url>", "URL to navigate to")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (url: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
try {
|
||||
const result = await browserNavigate(baseUrl, {
|
||||
url,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
const result = await callBrowserRequest<{ url?: string }>(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: "/navigate",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
url,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -40,22 +47,27 @@ export function registerBrowserNavigationCommands(
|
||||
.argument("<height>", "Viewport height", (v: string) => Number(v))
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (width: number, height: number, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
||||
defaultRuntime.error(danger("width and height must be numbers"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
const result = await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
kind: "resize",
|
||||
width,
|
||||
height,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
query: profile ? { profile } : undefined,
|
||||
body: {
|
||||
kind: "resize",
|
||||
width,
|
||||
height,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{ profile },
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
|
||||
Loaded 100 of 317 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user