import { randomUUID } from "node:crypto" ;
import fs from "node:fs/promises" ;
import path from "node:path" ;
import { resolveCliBackendLiveTest } from "../agents/cli-backends.js" ;
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
type DeviceIdentity,
} from "../infra/device-identity.js" ;
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
} from "../infra/device-pairing.js" ;
import { isTruthyEnvValue } from "../infra/env.js" ;
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js" ;
import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js" ;
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js" ;
import { GatewayClient, type GatewayClientOptions } from "./client.js" ;
// Aggregate docker live runs can contend on startup enough that the gateway
// websocket handshake needs a wider budget than the single-provider reruns.
const CLI_GATEWAY_CONNECT_TIMEOUT_MS = 60 _000 ;
export type BootstrapWorkspaceContext = {
expectedInjectedFiles: string[];
workspaceDir: string;
workspaceRootDir: string;
};
export type SystemPromptReport = {
injectedWorkspaceFiles?: Array<{ name?: string }>;
};
export type CliBackendLiveEnvSnapshot = {
configPath?: string;
stateDir?: string;
token?: string;
skipChannels?: string;
skipProviders?: string;
skipGmail?: string;
skipCron?: string;
skipCanvas?: string;
skipBrowserControl?: string;
bundledPluginsDir?: string;
minimalGateway?: string;
anthropicApiKey?: string;
anthropicApiKeyOld?: string;
};
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 resolveCliBackendLiveTest(providerId)?.defaultImageProbe === true ;
}
export function shouldRunCliMcpProbe(providerId: string): boolean {
const raw = process.env.OPENCLAW_LIVE_CLI_BACKEND_MCP_PROBE?.trim();
if (raw) {
return isTruthyEnvValue(raw);
}
return resolveCliBackendLiveTest(providerId)?.defaultMcpProbe === true ;
}
export function resolveCliBackendLiveArgs(params: {
providerId: string;
defaultArgs?: string[];
defaultResumeArgs?: string[];
}): { args: string[]; resumeArgs?: string[] } {
const args =
parseJsonStringArray(
"OPENCLAW_LIVE_CLI_BACKEND_ARGS" ,
process.env.OPENCLAW_LIVE_CLI_BACKEND_ARGS,
) ?? params.defaultArgs;
if (!args || args.length === 0 ) {
throw new Error(
`OPENCLAW_LIVE_CLI_BACKEND_ARGS is required for provider "${params.providerId}" .`,
);
}
const resumeArgs =
parseJsonStringArray(
"OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS" ,
process.env.OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS,
) ?? params.defaultResumeArgs;
return { args, resumeArgs };
}
export function resolveCliModelSwitchProbeTarget(
providerId: string,
modelRef: string,
): string | undefined {
const normalizedProvider = normalizeLowercaseStringOrEmpty(providerId);
const normalizedModelRef = normalizeLowercaseStringOrEmpty(modelRef);
if (normalizedProvider !== "claude-cli" ) {
return undefined;
}
if (normalizedModelRef !== "claude-cli/claude-sonnet-4-6" ) {
return undefined;
}
return "claude-cli/claude-opus-4-6" ;
}
export function shouldRunCliModelSwitchProbe(providerId: string, modelRef: string): boolean {
const raw = process.env.OPENCLAW_LIVE_CLI_BACKEND_MODEL_SWITCH_PROBE?.trim();
if (raw) {
return isTruthyEnvValue(raw);
}
return typeof resolveCliModelSwitchProbeTarget(providerId, modelRef) === "string" ;
}
export function matchesCliBackendReply(text: string, expected: string): boolean {
const normalized = text.trim();
const target = expected.trim();
const targetWithoutPeriod = target.slice(0 , -1 );
return (
normalized === target ||
normalized === targetWithoutPeriod ||
normalized.includes(target) ||
normalized.includes(targetWithoutPeriod)
);
}
export function withClaudeMcpConfigOverrides(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 };
}
function sleep(ms: number): Promise<void > {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function shouldRetryCliCronMcpProbeReply(text: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(text);
if (!normalized) {
return true ;
}
const mentionsCancellation =
normalized.includes("tool call was cancelled" ) ||
normalized.includes("tool call was canceled" ) ||
normalized.includes("tool call was cancelled before completion" ) ||
normalized.includes("tool call was canceled before completion" ) ||
normalized.includes("attempts were cancelled" ) ||
normalized.includes("attempts were canceled" ) ||
normalized.includes("cancelled by the environment" ) ||
normalized.includes("canceled by the environment" ) ||
normalized.includes("mcp call was cancelled" ) ||
normalized.includes("mcp call was canceled" );
const mentionsUserCancellation =
normalized.includes("user cancelled mcp tool call" ) ||
normalized.includes("user canceled mcp tool call" );
const mentionsCreateFailure =
normalized.includes("could not create " ) ||
normalized.includes("couldn't create " ) ||
normalized.includes("couldn’t create " ) ||
normalized.includes("could not create the job" ) ||
normalized.includes("couldn't create the job" ) ||
normalized.includes("couldn’t create the job" ) ||
normalized.includes("could not create job" ) ||
normalized.includes("couldn't create job" ) ||
normalized.includes("couldn’t create job" );
const mentionsRetryRequest =
normalized.includes("please retry" ) ||
normalized.includes("i can try again" ) ||
normalized.includes("i'll retry" ) ||
normalized.includes("i’ll retry" ) ||
normalized.includes("send the same request again" );
const mentionsMissingJob =
normalized.includes("job was not created" ) ||
normalized.includes("job still was not created" ) ||
normalized.includes("nothing was created" ) ||
normalized.includes("verify the cron job was created" ) ||
normalized.includes("was not created" );
if (mentionsUserCancellation) {
return true ;
}
return (
mentionsCancellation && (mentionsMissingJob || mentionsCreateFailure || mentionsRetryRequest)
);
}
export async function connectTestGatewayClient(params: {
url: string;
token: string;
deviceIdentity?: DeviceIdentity;
timeoutMs?: number;
maxAttemptTimeoutMs?: number;
clientDisplayName?: string | null ;
requestTimeoutMs?: number;
onRetry?: (attempt: number, error: Error) => void ;
}): Promise<GatewayClient> {
const timeoutMs = params.timeoutMs ?? CLI_GATEWAY_CONNECT_TIMEOUT_MS;
const maxAttemptTimeoutMs = params.maxAttemptTimeoutMs ?? 45 _000 ;
const startedAt = Date.now();
let attempt = 0 ;
let lastError: Error | null = null ;
while (Date.now() - startedAt < timeoutMs) {
attempt += 1 ;
const remainingMs = timeoutMs - (Date.now() - startedAt);
if (remainingMs <= 0 ) {
break ;
}
try {
return await connectClientOnce({
...params,
timeoutMs: Math.min(remainingMs, maxAttemptTimeoutMs),
});
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (!isRetryableGatewayConnectError(lastError) || remainingMs <= 5 _000 ) {
throw lastError;
}
params.onRetry?.(attempt, 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;
deviceIdentity?: DeviceIdentity;
clientDisplayName?: string | null ;
requestTimeoutMs?: 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}`) });
const clientOptions: GatewayClientOptions = {
url: params.url,
token: params.token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientVersion: "dev" ,
mode: GATEWAY_CLIENT_MODES.TEST,
connectChallengeTimeoutMs: params.timeoutMs,
deviceIdentity: params.deviceIdentity,
onHelloOk: () => finish({ client }),
onConnectError: (error) => finish({ error }),
onClose: failWithClose,
};
if (params.clientDisplayName !== null ) {
clientOptions.clientDisplayName = params.clientDisplayName ?? "vitest-live" ;
}
if (params.requestTimeoutMs !== undefined) {
clientOptions.requestTimeoutMs = params.requestTimeoutMs;
}
client = new GatewayClient(clientOptions);
const connectTimeout = setTimeout(
() => finish({ error: new Error("gateway connect timeout" ) }),
params.timeoutMs,
);
connectTimeout.unref();
client.start();
});
}
function isRetryableGatewayConnectError(error: Error): boolean {
const message = normalizeLowercaseStringOrEmpty(error.message);
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,
stateDir: process.env.OPENCLAW_STATE_DIR,
token: process.env.OPENCLAW_GATEWAY_TOKEN,
skipChannels: process.env.OPENCLAW_SKIP_CHANNELS,
skipProviders: process.env.OPENCLAW_SKIP_PROVIDERS,
skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER,
skipCron: process.env.OPENCLAW_SKIP_CRON,
skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
skipBrowserControl: process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER,
bundledPluginsDir: process.env.OPENCLAW_BUNDLED_PLUGINS_DIR,
minimalGateway: process.env.OPENCLAW_TEST_MINIMAL_GATEWAY,
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_PROVIDERS = "1" ;
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1" ;
process.env.OPENCLAW_SKIP_CRON = "1" ;
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1" ;
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1" ;
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "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_STATE_DIR" , snapshot.stateDir);
restoreEnvVar("OPENCLAW_GATEWAY_TOKEN" , snapshot.token);
restoreEnvVar("OPENCLAW_SKIP_CHANNELS" , snapshot.skipChannels);
restoreEnvVar("OPENCLAW_SKIP_PROVIDERS" , snapshot.skipProviders);
restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER" , snapshot.skipGmail);
restoreEnvVar("OPENCLAW_SKIP_CRON" , snapshot.skipCron);
restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST" , snapshot.skipCanvas);
restoreEnvVar("OPENCLAW_SKIP_BROWSER_CONTROL_SERVER" , snapshot.skipBrowserControl);
restoreEnvVar("OPENCLAW_BUNDLED_PLUGINS_DIR" , snapshot.bundledPluginsDir);
restoreEnvVar("OPENCLAW_TEST_MINIMAL_GATEWAY" , snapshot.minimalGateway);
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 ensurePairedTestGatewayClientIdentity(params?: {
displayName?: string;
}): Promise<DeviceIdentity> {
const identity = loadOrCreateDeviceIdentity();
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
const requiredScopes = ["operator.admin" ];
const paired = await getPairedDevice(identity.deviceId);
const pairedScopes = Array.isArray(paired?.approvedScopes)
? paired.approvedScopes
: Array.isArray(paired?.scopes)
? paired.scopes
: [];
if (
paired?.publicKey === publicKey &&
requiredScopes.every((scope) => pairedScopes.includes(scope))
) {
return identity;
}
const pairing = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey,
displayName: params?.displayName ?? "vitest" ,
platform: process.platform,
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
role: "operator" ,
scopes: requiredScopes,
silent: true ,
});
const approved = await approveDevicePairing(pairing.request.requestId, {
callerScopes: requiredScopes,
});
if (approved?.status !== "approved" ) {
throw new Error(
`failed to pre-pair live test device: ${approved?.status ?? "missing-approval-result" }`,
);
}
return identity;
}
Messung V0.5 in Prozent C=94 H=94 G=93
¤ Dauer der Verarbeitung: 0.11 Sekunden
(vorverarbeitet am 2026-06-05)
¤
*© Formatika GbR, Deutschland