refactor: split cli backend live helpers

This commit is contained in:
Peter Steinberger
2026-04-07 03:46:18 +01:00
parent 1092691d14
commit 416a3148e9
10 changed files with 630 additions and 516 deletions

View File

@@ -56,10 +56,6 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
policy: { routeConfigGuard: "always" },
route: { id: "gateway-status" },
},
{
commandPath: ["cron"],
policy: { bypassConfigGuard: true },
},
{
commandPath: ["sessions"],
exact: true,

View File

@@ -1,3 +1,4 @@
import { isGatewayConfigBypassCommandPath } from "../gateway/explicit-connection-policy.js";
import { cliCommandCatalog, type CliCommandPathPolicy } from "./command-catalog.js";
import { matchesCommandPath } from "./command-path-matches.js";
@@ -23,5 +24,11 @@ export function resolveCliCommandPathPolicy(commandPath: string[]): CliCommandPa
...entry.policy,
};
}
if (isGatewayConfigBypassCommandPath(commandPath)) {
resolvedPolicy = {
...resolvedPolicy,
bypassConfigGuard: true,
};
}
return resolvedPolicy;
}

View File

@@ -32,11 +32,13 @@ import {
GatewaySecretRefUnavailableError,
resolveGatewayCredentialsFromConfig,
trimToUndefined,
type ExplicitGatewayAuth,
type GatewayCredentialMode,
type GatewayCredentialPrecedence,
type GatewayRemoteCredentialFallback,
type GatewayRemoteCredentialPrecedence,
} from "./credentials.js";
import { canSkipGatewayConfigLoad } from "./explicit-connection-policy.js";
import {
CLI_DEFAULT_OPERATOR_SCOPES,
resolveLeastPrivilegeOperatorScopesForMethod,
@@ -200,10 +202,7 @@ function resolveDeviceIdentityForGatewayCall(): ReturnType<
}
}
export type ExplicitGatewayAuth = {
token?: string;
password?: string;
};
export type { ExplicitGatewayAuth } from "./credentials.js";
export function resolveExplicitGatewayAuth(opts?: ExplicitGatewayAuth): ExplicitGatewayAuth {
const token =
@@ -296,8 +295,11 @@ function resolveGatewayCallContext(opts: CallGatewayBaseOptions): ResolvedGatewa
: trimToUndefined(process.env.OPENCLAW_GATEWAY_URL);
const urlOverride = cliUrlOverride ?? envUrlOverride;
const urlOverrideSource = cliUrlOverride ? "cli" : envUrlOverride ? "env" : undefined;
const canSkipConfigLoad =
!opts.config && urlOverride && (explicitAuth.token || explicitAuth.password);
const canSkipConfigLoad = canSkipGatewayConfigLoad({
config: opts.config,
urlOverride,
explicitAuth,
});
const config = opts.config ?? (canSkipConfigLoad ? ({} as OpenClawConfig) : loadGatewayConfig());
const configPath = opts.configPath ?? resolveGatewayConfigPath(process.env);
const isRemoteMode = config.gateway?.mode === "remote";

View File

@@ -0,0 +1,22 @@
import type { OpenClawConfig } from "../config/config.js";
import { trimToUndefined, type ExplicitGatewayAuth } from "./credentials.js";
export function hasExplicitGatewayConnectionAuth(auth?: ExplicitGatewayAuth): boolean {
return Boolean(trimToUndefined(auth?.token) || trimToUndefined(auth?.password));
}
export function canSkipGatewayConfigLoad(params: {
config?: OpenClawConfig;
urlOverride?: string;
explicitAuth?: ExplicitGatewayAuth;
}): boolean {
return (
!params.config &&
Boolean(trimToUndefined(params.urlOverride)) &&
hasExplicitGatewayConnectionAuth(params.explicitAuth)
);
}
export function isGatewayConfigBypassCommandPath(commandPath: readonly string[]): boolean {
return commandPath[0] === "cron";
}

View File

@@ -0,0 +1,531 @@
import { execFile } from "node:child_process";
import { randomBytes, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { expect } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js";
import { GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
import { renderCatNoncePngBase64 } from "./live-image-probe.js";
import { extractPayloadText } from "./test-helpers.agent-results.js";
const execFileAsync = promisify(execFile);
const CLI_GATEWAY_CONNECT_TIMEOUT_MS = 30_000;
export const DEFAULT_CLAUDE_ARGS = [
"-p",
"--output-format",
"stream-json",
"--include-partial-messages",
"--verbose",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
];
export const DEFAULT_CODEX_ARGS = [
"exec",
"--json",
"--color",
"never",
"--sandbox",
"read-only",
"--skip-git-repo-check",
];
export const DEFAULT_CLEAR_ENV = [
"ANTHROPIC_API_KEY",
"ANTHROPIC_API_KEY_OLD",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_UNIX_SOCKET",
"CLAUDE_CONFIG_DIR",
"CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
"CLAUDE_CODE_OAUTH_SCOPES",
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
"CLAUDE_CODE_PLUGIN_CACHE_DIR",
"CLAUDE_CODE_PLUGIN_SEED_DIR",
"CLAUDE_CODE_REMOTE",
"CLAUDE_CODE_USE_COWORK_PLUGINS",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_FOUNDRY",
"CLAUDE_CODE_USE_VERTEX",
];
export type BootstrapWorkspaceContext = {
expectedInjectedFiles: string[];
workspaceDir: string;
workspaceRootDir: string;
};
export type SystemPromptReport = {
injectedWorkspaceFiles?: Array<{ name?: string }>;
};
export type CronListCliResult = {
jobs?: Array<{
id?: string;
name?: string;
sessionTarget?: string;
agentId?: string | null;
sessionKey?: string | null;
payload?: { kind?: string; text?: string; message?: string };
}>;
};
type CronListJob = NonNullable<CronListCliResult["jobs"]>[number];
export type CliBackendLiveEnvSnapshot = {
configPath?: string;
token?: string;
skipChannels?: string;
skipGmail?: string;
skipCron?: string;
skipCanvas?: string;
anthropicApiKey?: string;
anthropicApiKeyOld?: string;
};
export function randomImageProbeCode(len = 6): string {
// Chosen to avoid common OCR confusions in our 5x7 bitmap font.
// Notably: 0↔8, B↔8, 6↔9, 3↔B, D↔0.
const alphabet = "24567ACEF";
const bytes = randomBytes(len);
let out = "";
for (let i = 0; i < len; i += 1) {
out += alphabet[bytes[i] % alphabet.length];
}
return out;
}
export function parseJsonStringArray(name: string, raw?: string): string[] | undefined {
const trimmed = raw?.trim();
if (!trimmed) {
return undefined;
}
const parsed = JSON.parse(trimmed);
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
throw new Error(`${name} must be a JSON array of strings.`);
}
return parsed;
}
export function parseImageMode(raw?: string): "list" | "repeat" | undefined {
const trimmed = raw?.trim();
if (!trimmed) {
return undefined;
}
if (trimmed === "list" || trimmed === "repeat") {
return trimmed;
}
throw new Error("OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE must be 'list' or 'repeat'.");
}
export function shouldRunCliImageProbe(providerId: string): boolean {
const raw = process.env.OPENCLAW_LIVE_CLI_BACKEND_IMAGE_PROBE?.trim();
if (raw) {
return isTruthyEnvValue(raw);
}
return providerId === "claude-cli";
}
export function matchesCliBackendReply(text: string, expected: string): boolean {
const normalized = text.trim();
const target = expected.trim();
return normalized === target || normalized === target.slice(0, -1);
}
export function withMcpConfigOverrides(args: string[], mcpConfigPath: string): string[] {
const next = [...args];
if (!next.includes("--strict-mcp-config")) {
next.push("--strict-mcp-config");
}
if (!next.includes("--mcp-config")) {
next.push("--mcp-config", mcpConfigPath);
}
return next;
}
export async function getFreeGatewayPort(): Promise<number> {
return await getFreePortBlockWithPermissionFallback({
offsets: [0, 1, 2, 4],
fallbackBase: 40_000,
});
}
export async function createBootstrapWorkspace(
tempDir: string,
): Promise<BootstrapWorkspaceContext> {
const workspaceRootDir = path.join(tempDir, "workspace");
const workspaceDir = path.join(workspaceRootDir, "dev");
const expectedInjectedFiles = ["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md"];
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(
path.join(workspaceDir, "AGENTS.md"),
[
"# AGENTS.md",
"",
"Follow exact reply instructions from the user.",
"Do not add extra punctuation when the user asks for an exact response.",
].join("\n"),
);
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), `SOUL-${randomUUID()}\n`);
await fs.writeFile(path.join(workspaceDir, "IDENTITY.md"), `IDENTITY-${randomUUID()}\n`);
await fs.writeFile(path.join(workspaceDir, "USER.md"), `USER-${randomUUID()}\n`);
return { expectedInjectedFiles, workspaceDir, workspaceRootDir };
}
export async function runOpenClawCliJson<T>(args: string[], env: NodeJS.ProcessEnv): Promise<T> {
const childEnv = { ...env };
delete childEnv.VITEST;
delete childEnv.VITEST_MODE;
delete childEnv.VITEST_POOL_ID;
delete childEnv.VITEST_WORKER_ID;
const { stdout, stderr } = await execFileAsync(process.execPath, ["openclaw.mjs", ...args], {
cwd: process.cwd(),
env: childEnv,
timeout: 30_000,
maxBuffer: 1024 * 1024,
});
const trimmed = stdout.trim();
if (!trimmed) {
throw new Error(
[
`openclaw ${args.join(" ")} produced no JSON stdout`,
stderr.trim() ? `stderr: ${stderr.trim()}` : undefined,
]
.filter(Boolean)
.join("\n"),
);
}
try {
return JSON.parse(trimmed) as T;
} catch (error) {
throw new Error(
[
`openclaw ${args.join(" ")} returned invalid JSON`,
`stdout: ${trimmed}`,
stderr.trim() ? `stderr: ${stderr.trim()}` : undefined,
error instanceof Error ? `cause: ${error.message}` : undefined,
]
.filter(Boolean)
.join("\n"),
{ cause: error },
);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function connectTestGatewayClient(params: {
url: string;
token: string;
}): Promise<GatewayClient> {
const startedAt = Date.now();
let attempt = 0;
let lastError: Error | null = null;
while (Date.now() - startedAt < CLI_GATEWAY_CONNECT_TIMEOUT_MS) {
attempt += 1;
const remainingMs = CLI_GATEWAY_CONNECT_TIMEOUT_MS - (Date.now() - startedAt);
if (remainingMs <= 0) {
break;
}
try {
return await connectClientOnce({
...params,
timeoutMs: Math.min(remainingMs, 35_000),
});
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (!isRetryableGatewayConnectError(lastError) || remainingMs <= 5_000) {
throw lastError;
}
await sleep(Math.min(1_000 * attempt, 5_000));
}
}
throw lastError ?? new Error("gateway connect timeout");
}
async function connectClientOnce(params: {
url: string;
token: string;
timeoutMs: number;
}): Promise<GatewayClient> {
return await new Promise<GatewayClient>((resolve, reject) => {
let done = false;
let client: GatewayClient | undefined;
const finish = (result: { client?: GatewayClient; error?: Error }) => {
if (done) {
return;
}
done = true;
clearTimeout(connectTimeout);
if (result.error) {
if (client) {
void client.stopAndWait({ timeoutMs: 1_000 }).catch(() => {});
}
reject(result.error);
return;
}
resolve(result.client as GatewayClient);
};
const failWithClose = (code: number, reason: string) =>
finish({ error: new Error(`gateway closed during connect (${code}): ${reason}`) });
client = new GatewayClient({
url: params.url,
token: params.token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientVersion: "dev",
mode: "test",
requestTimeoutMs: params.timeoutMs,
connectChallengeTimeoutMs: params.timeoutMs,
onHelloOk: () => finish({ client }),
onConnectError: (error) => finish({ error }),
onClose: failWithClose,
});
const connectTimeout = setTimeout(
() => finish({ error: new Error("gateway connect timeout") }),
params.timeoutMs,
);
connectTimeout.unref();
client.start();
});
}
function isRetryableGatewayConnectError(error: Error): boolean {
const message = error.message.toLowerCase();
return (
message.includes("gateway closed during connect (1000)") ||
message.includes("gateway connect timeout") ||
message.includes("gateway connect challenge timeout") ||
message.includes("gateway request timeout for connect") ||
message.includes("gateway client stopped")
);
}
export function snapshotCliBackendLiveEnv(): CliBackendLiveEnvSnapshot {
return {
configPath: process.env.OPENCLAW_CONFIG_PATH,
token: process.env.OPENCLAW_GATEWAY_TOKEN,
skipChannels: process.env.OPENCLAW_SKIP_CHANNELS,
skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER,
skipCron: process.env.OPENCLAW_SKIP_CRON,
skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
anthropicApiKeyOld: process.env.ANTHROPIC_API_KEY_OLD,
};
}
export function applyCliBackendLiveEnv(preservedEnv: ReadonlySet<string>): void {
process.env.OPENCLAW_SKIP_CHANNELS = "1";
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
if (!preservedEnv.has("ANTHROPIC_API_KEY")) {
delete process.env.ANTHROPIC_API_KEY;
}
if (!preservedEnv.has("ANTHROPIC_API_KEY_OLD")) {
delete process.env.ANTHROPIC_API_KEY_OLD;
}
}
export function restoreCliBackendLiveEnv(snapshot: CliBackendLiveEnvSnapshot): void {
restoreEnvVar("OPENCLAW_CONFIG_PATH", snapshot.configPath);
restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", snapshot.token);
restoreEnvVar("OPENCLAW_SKIP_CHANNELS", snapshot.skipChannels);
restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER", snapshot.skipGmail);
restoreEnvVar("OPENCLAW_SKIP_CRON", snapshot.skipCron);
restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", snapshot.skipCanvas);
restoreEnvVar("ANTHROPIC_API_KEY", snapshot.anthropicApiKey);
restoreEnvVar("ANTHROPIC_API_KEY_OLD", snapshot.anthropicApiKeyOld);
}
function restoreEnvVar(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
export async function verifyCliBackendImageProbe(params: {
client: GatewayClient;
providerId: string;
sessionKey: string;
tempDir: string;
bootstrapWorkspace: BootstrapWorkspaceContext | null;
}): Promise<void> {
const imageCode = randomImageProbeCode();
const imageBase64 = renderCatNoncePngBase64(imageCode);
const runIdImage = randomUUID();
const imageFilePath = path.join(
params.bootstrapWorkspace?.workspaceDir ?? params.tempDir,
`probe-${runIdImage}.png`,
);
await fs.writeFile(imageFilePath, Buffer.from(imageBase64, "base64"));
const imageProbe = await params.client.request(
"agent",
params.providerId === "claude-cli"
? {
sessionKey: params.sessionKey,
idempotencyKey: `idem-${runIdImage}-image`,
message:
`Image path: ${imageFilePath}\n` +
"Best match: lobster, mouse, cat, horse. " +
"Reply with one lowercase word only.",
deliver: false,
}
: {
sessionKey: params.sessionKey,
idempotencyKey: `idem-${runIdImage}-image`,
message:
"Best match for the attached image: lobster, mouse, cat, horse. " +
"Reply with one lowercase word only.",
attachments: [
{
mimeType: "image/png",
fileName: `probe-${runIdImage}.png`,
content: imageBase64,
},
],
deliver: false,
},
{ expectFinal: true },
);
if (imageProbe?.status !== "ok") {
throw new Error(`image probe failed: status=${String(imageProbe?.status)}`);
}
const imageText = extractPayloadText(imageProbe?.result).trim().toLowerCase();
if (imageText !== "cat") {
throw new Error(`image probe expected 'cat', got: ${imageText}`);
}
}
export async function verifyClaudeCliCronMcpProbe(params: {
client: GatewayClient;
sessionKey: string;
port: number;
token: string;
env: NodeJS.ProcessEnv;
}): Promise<void> {
const cronProbeNonce = randomBytes(3).toString("hex").toUpperCase();
const cronProbeName = `live-mcp-${cronProbeNonce.toLowerCase()}`;
const cronProbeMessage = `probe-${cronProbeNonce.toLowerCase()}`;
const cronProbeAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
const cronArgsJson = JSON.stringify({
action: "add",
job: {
name: cronProbeName,
schedule: { kind: "at", at: cronProbeAt },
payload: { kind: "agentTurn", message: cronProbeMessage },
sessionTarget: "current",
enabled: true,
},
});
let createdJob: CronListJob | undefined;
let lastCronText = "";
for (let attempt = 0; attempt < 2 && !createdJob; attempt += 1) {
const runIdMcp = randomUUID();
const cronProbe = await params.client.request(
"agent",
{
sessionKey: params.sessionKey,
idempotencyKey: `idem-${runIdMcp}-mcp-${attempt}`,
message:
attempt === 0
? "Use the OpenClaw MCP tool named cron. " +
`Call it with JSON arguments ${cronArgsJson}. ` +
"Do the actual tool call; I will verify externally with the OpenClaw cron CLI. " +
`After the cron job is created, reply exactly: ${cronProbeName}`
: "Return only a tool call for the OpenClaw MCP tool `cron`. " +
`Use these exact JSON arguments: ${cronArgsJson}. ` +
"No prose. I will verify externally with the OpenClaw cron CLI.",
deliver: false,
},
{ expectFinal: true },
);
if (cronProbe?.status !== "ok") {
throw new Error(`cron mcp probe failed: status=${String(cronProbe?.status)}`);
}
lastCronText = extractPayloadText(cronProbe?.result).trim();
createdJob = await assertCronJobVisibleViaCli({
port: params.port,
token: params.token,
env: params.env,
expectedName: cronProbeName,
expectedMessage: cronProbeMessage,
});
if (!createdJob && attempt === 1) {
throw new Error(
`cron cli verify could not find job ${cronProbeName}: reply=${JSON.stringify(lastCronText)}`,
);
}
}
if (!createdJob) {
throw new Error(`cron cli verify did not create job ${cronProbeName}`);
}
expect(createdJob.name).toBe(cronProbeName);
expect(createdJob?.payload?.kind).toBe("agentTurn");
expect(createdJob?.payload?.message).toBe(cronProbeMessage);
expect(createdJob?.agentId).toBe("dev");
expect(createdJob?.sessionKey).toBe(params.sessionKey);
expect(createdJob?.sessionTarget).toBe(`session:${params.sessionKey}`);
if (createdJob?.id) {
await runOpenClawCliJson(
[
"cron",
"rm",
createdJob.id,
"--json",
"--url",
`ws://127.0.0.1:${params.port}`,
"--token",
params.token,
],
params.env,
);
}
}
export async function assertCronJobVisibleViaCli(params: {
port: number;
token: string;
env: NodeJS.ProcessEnv;
expectedName: string;
expectedMessage: string;
}): Promise<CronListJob | undefined> {
const cronList = await runOpenClawCliJson<CronListCliResult>(
[
"cron",
"list",
"--all",
"--json",
"--url",
`ws://127.0.0.1:${params.port}`,
"--token",
params.token,
],
params.env,
);
return (
cronList.jobs?.find((job) => job.name === params.expectedName) ??
cronList.jobs?.find((job) => job.payload?.message === params.expectedMessage)
);
}

View File

@@ -1,22 +1,34 @@
import { execFile } from "node:child_process";
import { randomBytes, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { isLiveTestEnabled } from "../agents/live-test-helpers.js";
import { parseModelRef } from "../agents/model-selection.js";
import { clearRuntimeConfigSnapshot, type OpenClawConfig } from "../config/config.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js";
import { GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
import { renderCatNoncePngBase64 } from "./live-image-probe.js";
import {
applyCliBackendLiveEnv,
createBootstrapWorkspace,
DEFAULT_CLAUDE_ARGS,
DEFAULT_CLEAR_ENV,
DEFAULT_CODEX_ARGS,
getFreeGatewayPort,
matchesCliBackendReply,
parseImageMode,
parseJsonStringArray,
restoreCliBackendLiveEnv,
shouldRunCliImageProbe,
snapshotCliBackendLiveEnv,
type SystemPromptReport,
verifyClaudeCliCronMcpProbe,
verifyCliBackendImageProbe,
withMcpConfigOverrides,
connectTestGatewayClient,
} from "./gateway-cli-backend.live-helpers.js";
import { startGatewayServer } from "./server.js";
import { extractPayloadText } from "./test-helpers.agent-results.js";
const execFileAsync = promisify(execFile);
const LIVE = isLiveTestEnabled();
const CLI_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND);
const CLI_RESUME = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE);
@@ -24,285 +36,6 @@ const describeLive = LIVE && CLI_LIVE ? describe : describe.skip;
const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-6";
const CLI_BACKEND_LIVE_TIMEOUT_MS = 420_000;
const CLI_GATEWAY_CONNECT_TIMEOUT_MS = 30_000;
const DEFAULT_CLAUDE_ARGS = [
"-p",
"--output-format",
"stream-json",
"--include-partial-messages",
"--verbose",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
];
const DEFAULT_CODEX_ARGS = [
"exec",
"--json",
"--color",
"never",
"--sandbox",
"read-only",
"--skip-git-repo-check",
];
const DEFAULT_CLEAR_ENV = [
"ANTHROPIC_API_KEY",
"ANTHROPIC_API_KEY_OLD",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_UNIX_SOCKET",
"CLAUDE_CONFIG_DIR",
"CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
"CLAUDE_CODE_OAUTH_SCOPES",
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
"CLAUDE_CODE_PLUGIN_CACHE_DIR",
"CLAUDE_CODE_PLUGIN_SEED_DIR",
"CLAUDE_CODE_REMOTE",
"CLAUDE_CODE_USE_COWORK_PLUGINS",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_FOUNDRY",
"CLAUDE_CODE_USE_VERTEX",
];
function randomImageProbeCode(len = 6): string {
// Chosen to avoid common OCR confusions in our 5x7 bitmap font.
// Notably: 0↔8, B↔8, 6↔9, 3↔B, D↔0.
// Must stay within the glyph set in `src/gateway/live-image-probe.ts`.
const alphabet = "24567ACEF";
const bytes = randomBytes(len);
let out = "";
for (let i = 0; i < len; i += 1) {
out += alphabet[bytes[i] % alphabet.length];
}
return out;
}
function parseJsonStringArray(name: string, raw?: string): string[] | undefined {
const trimmed = raw?.trim();
if (!trimmed) {
return undefined;
}
const parsed = JSON.parse(trimmed);
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
throw new Error(`${name} must be a JSON array of strings.`);
}
return parsed;
}
function parseImageMode(raw?: string): "list" | "repeat" | undefined {
const trimmed = raw?.trim();
if (!trimmed) {
return undefined;
}
if (trimmed === "list" || trimmed === "repeat") {
return trimmed;
}
throw new Error("OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE must be 'list' or 'repeat'.");
}
function shouldRunCliImageProbe(providerId: string): boolean {
const raw = process.env.OPENCLAW_LIVE_CLI_BACKEND_IMAGE_PROBE?.trim();
if (raw) {
return isTruthyEnvValue(raw);
}
return providerId === "claude-cli";
}
function matchesCliBackendReply(text: string, expected: string): boolean {
const normalized = text.trim();
const target = expected.trim();
return normalized === target || normalized === target.slice(0, -1);
}
function withMcpConfigOverrides(args: string[], mcpConfigPath: string): string[] {
const next = [...args];
if (!next.includes("--strict-mcp-config")) {
next.push("--strict-mcp-config");
}
if (!next.includes("--mcp-config")) {
next.push("--mcp-config", mcpConfigPath);
}
return next;
}
async function getFreeGatewayPort(): Promise<number> {
return await getFreePortBlockWithPermissionFallback({
offsets: [0, 1, 2, 4],
fallbackBase: 40_000,
});
}
type BootstrapWorkspaceContext = {
expectedInjectedFiles: string[];
workspaceDir: string;
workspaceRootDir: string;
};
type SystemPromptReport = {
injectedWorkspaceFiles?: Array<{ name?: string }>;
};
type CronListCliResult = {
jobs?: Array<{
id?: string;
name?: string;
sessionTarget?: string;
agentId?: string | null;
sessionKey?: string | null;
payload?: { kind?: string; text?: string; message?: string };
}>;
};
async function createBootstrapWorkspace(tempDir: string): Promise<BootstrapWorkspaceContext> {
const workspaceRootDir = path.join(tempDir, "workspace");
const workspaceDir = path.join(workspaceRootDir, "dev");
const expectedInjectedFiles = ["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md"];
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(
path.join(workspaceDir, "AGENTS.md"),
[
"# AGENTS.md",
"",
"Follow exact reply instructions from the user.",
"Do not add extra punctuation when the user asks for an exact response.",
].join("\n"),
);
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), `SOUL-${randomUUID()}\n`);
await fs.writeFile(path.join(workspaceDir, "IDENTITY.md"), `IDENTITY-${randomUUID()}\n`);
await fs.writeFile(path.join(workspaceDir, "USER.md"), `USER-${randomUUID()}\n`);
return { expectedInjectedFiles, workspaceDir, workspaceRootDir };
}
async function runOpenClawCliJson<T>(args: string[], env: NodeJS.ProcessEnv): Promise<T> {
const childEnv = { ...env };
delete childEnv.VITEST;
delete childEnv.VITEST_MODE;
delete childEnv.VITEST_POOL_ID;
delete childEnv.VITEST_WORKER_ID;
const { stdout, stderr } = await execFileAsync(process.execPath, ["openclaw.mjs", ...args], {
cwd: process.cwd(),
env: childEnv,
timeout: 30_000,
maxBuffer: 1024 * 1024,
});
const trimmed = stdout.trim();
if (!trimmed) {
throw new Error(
[
`openclaw ${args.join(" ")} produced no JSON stdout`,
stderr.trim() ? `stderr: ${stderr.trim()}` : undefined,
]
.filter(Boolean)
.join("\n"),
);
}
try {
return JSON.parse(trimmed) as T;
} catch (error) {
throw new Error(
[
`openclaw ${args.join(" ")} returned invalid JSON`,
`stdout: ${trimmed}`,
stderr.trim() ? `stderr: ${stderr.trim()}` : undefined,
error instanceof Error ? `cause: ${error.message}` : undefined,
]
.filter(Boolean)
.join("\n"),
{ cause: error },
);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function connectClient(params: { url: string; token: string }) {
const startedAt = Date.now();
let attempt = 0;
let lastError: Error | null = null;
while (Date.now() - startedAt < CLI_GATEWAY_CONNECT_TIMEOUT_MS) {
attempt += 1;
const remainingMs = CLI_GATEWAY_CONNECT_TIMEOUT_MS - (Date.now() - startedAt);
if (remainingMs <= 0) {
break;
}
try {
return await connectClientOnce({
...params,
timeoutMs: Math.min(remainingMs, 35_000),
});
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (!isRetryableGatewayConnectError(lastError) || remainingMs <= 5_000) {
throw lastError;
}
await sleep(Math.min(1_000 * attempt, 5_000));
}
}
throw lastError ?? new Error("gateway connect timeout");
}
async function connectClientOnce(params: { url: string; token: string; timeoutMs: number }) {
return await new Promise<GatewayClient>((resolve, reject) => {
let done = false;
let client: GatewayClient | undefined;
const finish = (result: { client?: GatewayClient; error?: Error }) => {
if (done) {
return;
}
done = true;
clearTimeout(connectTimeout);
if (result.error) {
if (client) {
void client.stopAndWait({ timeoutMs: 1_000 }).catch(() => {});
}
reject(result.error);
return;
}
resolve(result.client as GatewayClient);
};
const failWithClose = (code: number, reason: string) =>
finish({ error: new Error(`gateway closed during connect (${code}): ${reason}`) });
client = new GatewayClient({
url: params.url,
token: params.token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientVersion: "dev",
mode: "test",
requestTimeoutMs: params.timeoutMs,
connectChallengeTimeoutMs: params.timeoutMs,
onHelloOk: () => finish({ client }),
onConnectError: (error) => finish({ error }),
onClose: failWithClose,
});
const connectTimeout = setTimeout(
() => finish({ error: new Error("gateway connect timeout") }),
params.timeoutMs,
);
connectTimeout.unref();
client.start();
});
}
function isRetryableGatewayConnectError(error: Error): boolean {
const message = error.message.toLowerCase();
return (
message.includes("gateway closed during connect (1000)") ||
message.includes("gateway connect timeout") ||
message.includes("gateway connect challenge timeout") ||
message.includes("gateway request timeout for connect") ||
message.includes("gateway client stopped")
);
}
describeLive("gateway live (cli backend)", () => {
it(
@@ -314,29 +47,10 @@ describeLive("gateway live (cli backend)", () => {
process.env.OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV,
) ?? [],
);
const previousEnv = snapshotCliBackendLiveEnv();
clearRuntimeConfigSnapshot();
const previous = {
configPath: process.env.OPENCLAW_CONFIG_PATH,
token: process.env.OPENCLAW_GATEWAY_TOKEN,
skipChannels: process.env.OPENCLAW_SKIP_CHANNELS,
skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER,
skipCron: process.env.OPENCLAW_SKIP_CRON,
skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
anthropicApiKeyOld: process.env.ANTHROPIC_API_KEY_OLD,
};
process.env.OPENCLAW_SKIP_CHANNELS = "1";
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
if (!preservedEnv.has("ANTHROPIC_API_KEY")) {
delete process.env.ANTHROPIC_API_KEY;
}
if (!preservedEnv.has("ANTHROPIC_API_KEY_OLD")) {
delete process.env.ANTHROPIC_API_KEY_OLD;
}
applyCliBackendLiveEnv(preservedEnv);
const token = `test-${randomUUID()}`;
process.env.OPENCLAW_GATEWAY_TOKEN = token;
@@ -349,10 +63,10 @@ describeLive("gateway live (cli backend)", () => {
`OPENCLAW_LIVE_CLI_BACKEND_MODEL must resolve to a CLI backend model. Got: ${rawModel}`,
);
}
const providerId = parsed.provider;
const modelKey = `${providerId}/${parsed.model}`;
const enableCliImageProbe = shouldRunCliImageProbe(providerId);
const providerDefaults =
providerId === "claude-cli"
? {
@@ -374,6 +88,7 @@ describeLive("gateway live (cli backend)", () => {
`OPENCLAW_LIVE_CLI_BACKEND_COMMAND is required for provider "${providerId}".`,
);
}
const baseCliArgs =
parseJsonStringArray(
"OPENCLAW_LIVE_CLI_BACKEND_ARGS",
@@ -382,6 +97,7 @@ describeLive("gateway live (cli backend)", () => {
if (!baseCliArgs || baseCliArgs.length === 0) {
throw new Error(`OPENCLAW_LIVE_CLI_BACKEND_ARGS is required for provider "${providerId}".`);
}
const cliClearEnv =
parseJsonStringArray(
"OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV",
@@ -398,7 +114,6 @@ describeLive("gateway live (cli backend)", () => {
const cliImageMode =
parseImageMode(process.env.OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE) ??
providerDefaults?.imageMode;
if (cliImageMode && !cliImageArg) {
throw new Error(
"OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE requires OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG.",
@@ -438,9 +153,7 @@ describeLive("gateway live (cli backend)", () => {
...cfg.agents?.defaults,
...(bootstrapWorkspace ? { workspace: bootstrapWorkspace.workspaceRootDir } : {}),
model: { primary: modelKey },
models: {
[modelKey]: {},
},
models: { [modelKey]: {} },
cliBackends: {
...existingBackends,
[providerId]: {
@@ -465,26 +178,23 @@ describeLive("gateway live (cli backend)", () => {
auth: { mode: "token", token },
controlUiEnabled: false,
});
const client = await connectClient({
const client = await connectTestGatewayClient({
url: `ws://127.0.0.1:${port}`,
token,
});
try {
const sessionKey = "agent:dev:live-cli-backend";
const runId = randomUUID();
const nonce = randomBytes(3).toString("hex").toUpperCase();
const message =
providerId === "codex-cli"
? `Please include the token CLI-BACKEND-${nonce} in your reply.`
: `Reply with exactly: CLI backend OK ${nonce}.`;
const payload = await client.request(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runId}`,
message,
idempotencyKey: `idem-${randomUUID()}`,
message:
providerId === "codex-cli"
? `Please include the token CLI-BACKEND-${nonce} in your reply.`
: `Reply with exactly: CLI backend OK ${nonce}.`,
deliver: false,
},
{ expectFinal: true },
@@ -492,6 +202,7 @@ describeLive("gateway live (cli backend)", () => {
if (payload?.status !== "ok") {
throw new Error(`agent status=${String(payload?.status)}`);
}
const text = extractPayloadText(payload?.result);
if (providerId === "codex-cli") {
expect(text).toContain(`CLI-BACKEND-${nonce}`);
@@ -508,18 +219,16 @@ describeLive("gateway live (cli backend)", () => {
}
if (CLI_RESUME) {
const runIdResume = randomUUID();
const resumeNonce = randomBytes(3).toString("hex").toUpperCase();
const resumeMessage =
providerId === "codex-cli"
? `Please include the token CLI-RESUME-${resumeNonce} in your reply.`
: `Reply with exactly: CLI backend RESUME OK ${resumeNonce}.`;
const resumePayload = await client.request(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runIdResume}`,
message: resumeMessage,
idempotencyKey: `idem-${randomUUID()}`,
message:
providerId === "codex-cli"
? `Please include the token CLI-RESUME-${resumeNonce} in your reply.`
: `Reply with exactly: CLI backend RESUME OK ${resumeNonce}.`,
deliver: false,
},
{ expectFinal: true },
@@ -538,187 +247,30 @@ describeLive("gateway live (cli backend)", () => {
}
if (enableCliImageProbe) {
const imageCode = randomImageProbeCode();
const imageBase64 = renderCatNoncePngBase64(imageCode);
const runIdImage = randomUUID();
const imageFilePath = path.join(
bootstrapWorkspace?.workspaceDir ?? tempDir,
`probe-${runIdImage}.png`,
);
await fs.writeFile(imageFilePath, Buffer.from(imageBase64, "base64"));
const imageProbe = await client.request(
"agent",
providerId === "claude-cli"
? {
sessionKey,
idempotencyKey: `idem-${runIdImage}-image`,
message:
`Image path: ${imageFilePath}\n` +
"Best match: lobster, mouse, cat, horse. " +
"Reply with one lowercase word only.",
deliver: false,
}
: {
sessionKey,
idempotencyKey: `idem-${runIdImage}-image`,
message:
"Best match for the attached image: lobster, mouse, cat, horse. " +
"Reply with one lowercase word only.",
attachments: [
{
mimeType: "image/png",
fileName: `probe-${runIdImage}.png`,
content: imageBase64,
},
],
deliver: false,
},
{ expectFinal: true },
);
if (imageProbe?.status !== "ok") {
throw new Error(`image probe failed: status=${String(imageProbe?.status)}`);
}
const imageText = extractPayloadText(imageProbe?.result).trim().toLowerCase();
if (imageText !== "cat") {
throw new Error(`image probe expected 'cat', got: ${imageText}`);
}
await verifyCliBackendImageProbe({
client,
providerId,
sessionKey,
tempDir,
bootstrapWorkspace,
});
}
if (providerId === "claude-cli") {
const cronProbeNonce = randomBytes(3).toString("hex").toUpperCase();
const cronProbeName = `live-mcp-${cronProbeNonce.toLowerCase()}`;
const cronProbeMessage = `probe-${cronProbeNonce.toLowerCase()}`;
const cronProbeAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
const cronArgsJson = JSON.stringify({
action: "add",
job: {
name: cronProbeName,
schedule: { kind: "at", at: cronProbeAt },
payload: { kind: "agentTurn", message: cronProbeMessage },
sessionTarget: "current",
enabled: true,
},
await verifyClaudeCliCronMcpProbe({
client,
sessionKey,
port,
token,
env: process.env,
});
let createdJob: CronListCliResult["jobs"] extends Array<infer T> ? T | undefined : never;
let lastCronText = "";
for (let attempt = 0; attempt < 2 && !createdJob; attempt += 1) {
const runIdMcp = randomUUID();
const cronProbe = await client.request(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runIdMcp}-mcp-${attempt}`,
message:
attempt === 0
? "Use the OpenClaw MCP tool named cron. " +
`Call it with JSON arguments ${cronArgsJson}. ` +
"Do the actual tool call; I will verify externally with the OpenClaw cron CLI. " +
`After the cron job is created, reply exactly: ${cronProbeName}`
: "Return only a tool call for the OpenClaw MCP tool `cron`. " +
`Use these exact JSON arguments: ${cronArgsJson}. ` +
"No prose. I will verify externally with the OpenClaw cron CLI.",
deliver: false,
},
{ expectFinal: true },
);
if (cronProbe?.status !== "ok") {
throw new Error(`cron mcp probe failed: status=${String(cronProbe?.status)}`);
}
lastCronText = extractPayloadText(cronProbe?.result).trim();
const cronList = await runOpenClawCliJson<CronListCliResult>(
[
"cron",
"list",
"--all",
"--json",
"--url",
`ws://127.0.0.1:${port}`,
"--token",
token,
],
process.env,
);
createdJob =
cronList.jobs?.find((job) => job.name === cronProbeName) ??
cronList.jobs?.find((job) => job.payload?.message === cronProbeMessage);
if (!createdJob && attempt === 1) {
throw new Error(
`cron cli verify could not find job ${cronProbeName}: reply=${JSON.stringify(lastCronText)} list=${JSON.stringify(cronList)}`,
);
}
}
if (!createdJob) {
throw new Error(`cron cli verify did not create job ${cronProbeName}`);
}
expect(createdJob.name).toBe(cronProbeName);
expect(createdJob?.payload?.kind).toBe("agentTurn");
expect(createdJob?.payload?.message).toBe(cronProbeMessage);
expect(createdJob?.agentId).toBe("dev");
expect(createdJob?.sessionKey).toBe(sessionKey);
expect(createdJob?.sessionTarget).toBe(`session:${sessionKey}`);
if (createdJob?.id) {
await runOpenClawCliJson(
[
"cron",
"rm",
createdJob.id,
"--json",
"--url",
`ws://127.0.0.1:${port}`,
"--token",
token,
],
process.env,
);
}
}
} finally {
clearRuntimeConfigSnapshot();
await client.stopAndWait();
await server.close();
await fs.rm(tempDir, { recursive: true, force: true });
if (previous.configPath === undefined) {
delete process.env.OPENCLAW_CONFIG_PATH;
} else {
process.env.OPENCLAW_CONFIG_PATH = previous.configPath;
}
if (previous.token === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previous.token;
}
if (previous.skipChannels === undefined) {
delete process.env.OPENCLAW_SKIP_CHANNELS;
} else {
process.env.OPENCLAW_SKIP_CHANNELS = previous.skipChannels;
}
if (previous.skipGmail === undefined) {
delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
} else {
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = previous.skipGmail;
}
if (previous.skipCron === undefined) {
delete process.env.OPENCLAW_SKIP_CRON;
} else {
process.env.OPENCLAW_SKIP_CRON = previous.skipCron;
}
if (previous.skipCanvas === undefined) {
delete process.env.OPENCLAW_SKIP_CANVAS_HOST;
} else {
process.env.OPENCLAW_SKIP_CANVAS_HOST = previous.skipCanvas;
}
if (previous.anthropicApiKey === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = previous.anthropicApiKey;
}
if (previous.anthropicApiKeyOld === undefined) {
delete process.env.ANTHROPIC_API_KEY_OLD;
} else {
process.env.ANTHROPIC_API_KEY_OLD = previous.anthropicApiKeyOld;
}
restoreCliBackendLiveEnv(previousEnv);
}
},
CLI_BACKEND_LIVE_TIMEOUT_MS,

View File

@@ -45,7 +45,7 @@ export class McpLoopbackToolCache {
sessionKey: params.sessionKey,
messageProvider: params.messageProvider,
accountId: params.accountId,
applyDefaultGatewayHttpDeny: false,
surface: "loopback",
excludeToolNames: NATIVE_TOOL_EXCLUDE,
});
const nextEntry: CachedScopedTools = {

View File

@@ -103,7 +103,7 @@ describe("mcp loopback server", () => {
sessionKey: "agent:main:telegram:group:chat123",
accountId: "work",
messageProvider: "telegram",
applyDefaultGatewayHttpDeny: false,
surface: "loopback",
}),
);
});

View File

@@ -21,6 +21,8 @@ import { getPluginToolMeta } from "../plugins/tools.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js";
export type GatewayScopedToolSurface = "http" | "loopback";
export function resolveGatewayScopedTools(params: {
cfg: ReturnType<typeof loadConfig>;
sessionKey: string;
@@ -30,7 +32,7 @@ export function resolveGatewayScopedTools(params: {
agentThreadId?: string;
allowGatewaySubagentBinding?: boolean;
allowMediaInvokeCommands?: boolean;
applyDefaultGatewayHttpDeny?: boolean;
surface?: GatewayScopedToolSurface;
excludeToolNames?: Iterable<string>;
disablePluginTools?: boolean;
}) {
@@ -112,10 +114,12 @@ export function resolveGatewayScopedTools(params: {
],
});
const surface = params.surface ?? "http";
const gatewayToolsCfg = params.cfg.gateway?.tools;
const defaultGatewayDeny = params.applyDefaultGatewayHttpDeny
? DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter((name) => !gatewayToolsCfg?.allow?.includes(name))
: [];
const defaultGatewayDeny =
surface === "http"
? DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter((name) => !gatewayToolsCfg?.allow?.includes(name))
: [];
const gatewayDenySet = new Set([
...defaultGatewayDeny,
...(Array.isArray(gatewayToolsCfg?.deny) ? gatewayToolsCfg.deny : []),

View File

@@ -237,7 +237,7 @@ export async function handleToolsInvokeHttpRequest(
agentThreadId,
allowGatewaySubagentBinding: true,
allowMediaInvokeCommands: true,
applyDefaultGatewayHttpDeny: true,
surface: "http",
disablePluginTools: isKnownCoreToolId(toolName),
});
// Owner semantics intentionally follow the same shared-secret HTTP contract