From a3c6164a8de58be4bf718424f82acb9e108aac62 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 16:46:11 +0100 Subject: [PATCH] test: add ACP spawn defaults live Docker test --- scripts/test-live-acp-bind-docker.sh | 14 +- .../test-live-acp-spawn-defaults-docker.sh | 18 + .../gateway-acp-spawn-defaults.live.test.ts | 428 ++++++++++++++++++ 3 files changed, 459 insertions(+), 1 deletion(-) create mode 100755 scripts/test-live-acp-spawn-defaults-docker.sh create mode 100644 src/gateway/gateway-acp-spawn-defaults.live.test.ts diff --git a/scripts/test-live-acp-bind-docker.sh b/scripts/test-live-acp-bind-docker.sh index afac035c0790..9130593febc6 100644 --- a/scripts/test-live-acp-bind-docker.sh +++ b/scripts/test-live-acp-bind-docker.sh @@ -249,7 +249,7 @@ openclaw_live_stage_state_dir "$tmp_dir/.openclaw-state" openclaw_live_prepare_staged_config cd "$tmp_dir" export OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND="${OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND:-}" -node scripts/test-live.mjs -- src/gateway/gateway-acp-bind.live.test.ts +node scripts/test-live.mjs -- ${OPENCLAW_LIVE_ACP_BIND_TEST_FILES:-src/gateway/gateway-acp-bind.live.test.ts} EOF openclaw_live_acp_bind_append_build_extension acpx @@ -349,6 +349,7 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do echo "==> Run ACP bind live test in Docker" echo "==> Agent: $ACP_AGENT" + echo "==> Test files: ${OPENCLAW_LIVE_ACP_BIND_TEST_FILES:-src/gateway/gateway-acp-bind.live.test.ts}" echo "==> Profile file: $PROFILE_STATUS" echo "==> Auth dirs: ${AUTH_DIRS_CSV:-none}" echo "==> Auth files: ${AUTH_FILES_CSV:-none}" @@ -365,6 +366,9 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do -e GOOGLE_API_KEY \ -e FACTORY_API_KEY \ -e OPENAI_API_KEY \ + -e CODEX_API_KEY \ + -e ACPX_AUTH_OPENAI_API_KEY \ + -e ACPX_AUTH_CODEX_API_KEY \ -e OPENCODE_API_KEY \ -e OPENCODE_ZEN_API_KEY \ -e OPENCODE_CONFIG_CONTENT \ @@ -381,8 +385,16 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do -e OPENCLAW_LIVE_TEST=1 \ -e OPENCLAW_LIVE_ACP_BIND=1 \ -e OPENCLAW_LIVE_ACP_BIND_AGENT="$ACP_AGENT" \ + -e OPENCLAW_LIVE_ACP_BIND_TEST_FILES="${OPENCLAW_LIVE_ACP_BIND_TEST_FILES:-}" \ + -e OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL="${OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL:-}" \ -e OPENCLAW_LIVE_ACP_BIND_SETUP_TIMEOUT_SECONDS="${OPENCLAW_LIVE_ACP_BIND_SETUP_TIMEOUT_SECONDS:-180}" \ -e OPENCLAW_LIVE_ACP_BIND_OPENCODE_MODEL="${OPENCLAW_LIVE_ACP_BIND_OPENCODE_MODEL:-opencode/kimi-k2.6}" \ + -e OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS="${OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS:-}" \ + -e OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_AGENT="${OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_AGENT:-}" \ + -e OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_CONNECT_TIMEOUT_MS="${OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_CONNECT_TIMEOUT_MS:-}" \ + -e OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_MODEL="${OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_MODEL:-}" \ + -e OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_THINKING="${OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_THINKING:-}" \ + -e OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_TIMEOUT_MS="${OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_TIMEOUT_MS:-}" \ -e OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND="$AGENT_COMMAND") openclaw_live_append_array DOCKER_RUN_ARGS DOCKER_HOME_MOUNT openclaw_live_append_array DOCKER_RUN_ARGS DOCKER_TRUSTED_HARNESS_MOUNT diff --git a/scripts/test-live-acp-spawn-defaults-docker.sh b/scripts/test-live-acp-spawn-defaults-docker.sh new file mode 100755 index 000000000000..d5e53c323045 --- /dev/null +++ b/scripts/test-live-acp-spawn-defaults-docker.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ -n "${OPENCLAW_LIVE_ACP_BIND_AGENTS:-}" && "${OPENCLAW_LIVE_ACP_BIND_AGENTS}" != "codex" ]]; then + echo "ERROR: ACP spawn defaults Docker test supports only OPENCLAW_LIVE_ACP_BIND_AGENTS=codex." >&2 + exit 1 +fi + +export OPENCLAW_LIVE_ACP_BIND_AGENTS=codex +export OPENCLAW_LIVE_ACP_BIND_TEST_FILES="${OPENCLAW_LIVE_ACP_BIND_TEST_FILES:-src/gateway/gateway-acp-spawn-defaults.live.test.ts}" +export OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS=1 +export OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_MODEL="${OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_MODEL:-openai/gpt-5.5}" +export OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_THINKING="${OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_THINKING:-high}" +export OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL="${OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL:-gpt-5.5}" + +exec bash "$SCRIPT_DIR/test-live-acp-bind-docker.sh" diff --git a/src/gateway/gateway-acp-spawn-defaults.live.test.ts b/src/gateway/gateway-acp-spawn-defaults.live.test.ts new file mode 100644 index 000000000000..f4bb368e034d --- /dev/null +++ b/src/gateway/gateway-acp-spawn-defaults.live.test.ts @@ -0,0 +1,428 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { getAcpSessionManager } from "../acp/control-plane/manager.js"; +import { getAcpRuntimeBackend } from "../acp/runtime/registry.js"; +import { isSpawnAcpAcceptedResult, spawnAcpDirect } from "../agents/acp-spawn.js"; +import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; +import { + clearConfigCache, + clearRuntimeConfigSnapshot, + getRuntimeConfig, +} from "../config/config.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { loadSessionStore } from "../config/sessions/store.js"; +import type { SessionEntry } from "../config/sessions/types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isTruthyEnvValue } from "../infra/env.js"; +import { clearPluginLoaderCache } from "../plugins/loader.js"; +import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; +import { sleep } from "../utils.js"; +import { startGatewayServer } from "./server.js"; + +const LIVE = isLiveTestEnabled(); +const ACP_SPAWN_DEFAULTS_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS); +const describeLive = LIVE && ACP_SPAWN_DEFAULTS_LIVE ? describe : describe.skip; +const CONNECT_TIMEOUT_MS = resolvePositiveInteger( + process.env.OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_CONNECT_TIMEOUT_MS, + 90_000, +); +const LIVE_TIMEOUT_MS = resolvePositiveInteger( + process.env.OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_TIMEOUT_MS, + 240_000, +); + +function resolvePositiveInteger(raw: string | undefined, fallback: number): number { + const parsed = raw ? Number(raw) : Number.NaN; + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +} + +function resolveSubagentModel(): string { + return process.env.OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_MODEL?.trim() || "openai/gpt-5.5"; +} + +function resolveThinking(): string { + return process.env.OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_THINKING?.trim() || "high"; +} + +function resolveHarnessModel(): string { + return process.env.OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL?.trim() || "gpt-5.5"; +} + +function resolveAcpAgentId(): string { + return process.env.OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS_AGENT?.trim() || "codex"; +} + +function resolveAcpAgentCommand(): { command: string; args?: string[] } { + const codexHome = process.env.CODEX_HOME?.trim(); + return { + command: "env", + args: [ + ...(codexHome ? [`CODEX_HOME=${codexHome}`] : []), + process.execPath, + path.join(process.cwd(), "node_modules/@zed-industries/codex-acp/bin/codex-acp.js"), + ], + }; +} + +async function prepareCodexHomeForLiveSpawnDefaultsTest(tempRoot: string): Promise { + const home = process.env.HOME?.trim(); + const sourceCodexHome = process.env.CODEX_HOME?.trim() || (home ? path.join(home, ".codex") : ""); + const codexHome = path.join(tempRoot, "codex-home"); + await fs.mkdir(codexHome, { recursive: true }); + if (sourceCodexHome) { + await fs + .copyFile(path.join(sourceCodexHome, "auth.json"), path.join(codexHome, "auth.json")) + .catch((error) => { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } + }); + } + const sourceConfigPath = sourceCodexHome ? path.join(sourceCodexHome, "config.toml") : ""; + const targetConfigPath = path.join(codexHome, "config.toml"); + let rawConfig = ""; + try { + rawConfig = sourceConfigPath ? await fs.readFile(sourceConfigPath, "utf8") : ""; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } + } + const modelLine = `model = ${JSON.stringify(resolveHarnessModel())}`; + const nextConfig = /^model\s*=.*$/m.test(rawConfig) + ? rawConfig.replace(/^model\s*=.*$/m, modelLine) + : `${modelLine}\n${rawConfig}`; + await fs.writeFile(targetConfigPath, nextConfig, "utf8"); + process.env.CODEX_HOME = codexHome; +} + +async function waitForGatewayPort(params: { + host: string; + port: number; + timeoutMs?: number; +}): Promise { + const timeoutMs = params.timeoutMs ?? CONNECT_TIMEOUT_MS; + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const connected = await new Promise((resolve) => { + const socket = net.createConnection({ host: params.host, port: params.port }); + const finish = (ok: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(ok); + }; + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.setTimeout(1_000, () => finish(false)); + }); + if (connected) { + return; + } + await sleep(250); + } + + throw new Error(`timed out waiting for gateway port ${params.host}:${String(params.port)}`); +} + +async function getFreeGatewayPort(): Promise { + const { getFreePortBlockWithPermissionFallback } = await import("../test-utils/ports.js"); + return await getFreePortBlockWithPermissionFallback({ + offsets: [0, 1, 2, 4], + fallbackBase: 42_000, + }); +} + +async function waitForAcpBackendReady(timeoutMs = CONNECT_TIMEOUT_MS): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const backend = getAcpRuntimeBackend("acpx"); + const runtime = backend?.runtime as { probeAvailability?: () => Promise } | undefined; + if (backend && (!backend.healthy || backend.healthy())) { + return; + } + await runtime?.probeAvailability?.().catch(() => {}); + if (backend && (!backend.healthy || backend.healthy())) { + return; + } + await sleep(1_000); + } + throw new Error("timed out waiting for acpx backend readiness"); +} + +async function waitForSessionEntry(params: { + cfg: OpenClawConfig; + sessionKey: string; + timeoutMs?: number; +}): Promise { + const timeoutMs = params.timeoutMs ?? 20_000; + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: "codex" }); + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const entry = loadSessionStore(storePath)[params.sessionKey]; + if (entry) { + return entry; + } + await sleep(250); + } + throw new Error(`timed out waiting for ACP session entry ${params.sessionKey}`); +} + +function createConfig(params: { + port: number; + tempRoot: string; + acpAgentId: string; + subagentModel?: string; + thinking?: string; + includePrimaryOnlyAcpAgent?: boolean; +}): OpenClawConfig { + return { + agents: { + list: params.includePrimaryOnlyAcpAgent + ? [ + { + id: "codex-acp-primary-only", + runtime: { + type: "acp", + acp: { agent: params.acpAgentId }, + }, + model: "anthropic/claude-sonnet-4-6", + }, + ] + : undefined, + defaults: { + model: { + primary: "openai/gpt-5.5", + }, + subagents: { + allowAgents: ["*"], + maxSpawnDepth: 2, + ...(params.subagentModel ? { model: params.subagentModel } : {}), + }, + models: { + ...(params.subagentModel && params.thinking + ? { + [params.subagentModel]: { + params: { + thinking: params.thinking, + }, + }, + } + : {}), + }, + }, + }, + gateway: { + mode: "local", + bind: "loopback", + port: params.port, + }, + session: { + mainKey: "main", + scope: "per-sender", + store: path.join(params.tempRoot, "sessions.json"), + }, + acp: { + enabled: true, + backend: "acpx", + defaultAgent: params.acpAgentId, + allowedAgents: [params.acpAgentId], + }, + plugins: { + enabled: true, + allow: ["acpx"], + entries: { + acpx: { + enabled: true, + config: { + probeAgent: params.acpAgentId, + permissionMode: "approve-all", + nonInteractivePermissions: "deny", + agents: { + [params.acpAgentId]: resolveAcpAgentCommand(), + }, + }, + }, + }, + }, + }; +} + +describeLive("gateway live (ACP spawn defaults)", () => { + it( + "applies existing subagent defaults to live ACP spawns without leaking primary agent model", + async () => { + const previous = { + configPath: process.env.OPENCLAW_CONFIG_PATH, + stateDir: process.env.OPENCLAW_STATE_DIR, + token: process.env.OPENCLAW_GATEWAY_TOKEN, + port: process.env.OPENCLAW_GATEWAY_PORT, + 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, + codexHome: process.env.CODEX_HOME, + }; + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-acp-spawn-")); + const tempConfigPath = path.join(tempRoot, "openclaw.json"); + const tempStateDir = path.join(tempRoot, "state"); + const port = await getFreeGatewayPort(); + const token = `test-${randomUUID()}`; + const acpAgentId = resolveAcpAgentId(); + const subagentModel = resolveSubagentModel(); + const thinking = resolveThinking(); + const sessionKeys: string[] = []; + + process.env.OPENCLAW_CONFIG_PATH = tempConfigPath; + process.env.OPENCLAW_STATE_DIR = tempStateDir; + 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"; + process.env.OPENCLAW_GATEWAY_TOKEN = token; + process.env.OPENCLAW_GATEWAY_PORT = String(port); + await prepareCodexHomeForLiveSpawnDefaultsTest(tempRoot); + + const cfg = createConfig({ + port, + tempRoot, + acpAgentId, + subagentModel, + thinking, + includePrimaryOnlyAcpAgent: true, + }); + await fs.writeFile(tempConfigPath, `${JSON.stringify(cfg, null, 2)}\n`); + clearConfigCache(); + clearRuntimeConfigSnapshot(); + clearPluginLoaderCache(); + resetPluginRuntimeStateForTest(); + + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "token", token }, + controlUiEnabled: false, + }); + try { + await waitForGatewayPort({ host: "127.0.0.1", port, timeoutMs: CONNECT_TIMEOUT_MS }); + await waitForAcpBackendReady(); + const runtimeCfg = getRuntimeConfig(); + const configuredDefaultResult = await spawnAcpDirect( + { + task: "Reply with exactly LIVE-ACP-SPAWN-DEFAULTS-OK", + agentId: acpAgentId, + mode: "run", + }, + { agentSessionKey: "agent:main:main" }, + ); + if (!isSpawnAcpAcceptedResult(configuredDefaultResult)) { + throw new Error( + `configured default ACP spawn failed (${configuredDefaultResult.errorCode}): ${configuredDefaultResult.error}`, + ); + } + expect(isSpawnAcpAcceptedResult(configuredDefaultResult)).toBe(true); + sessionKeys.push(configuredDefaultResult.childSessionKey); + const configuredDefaultEntry = await waitForSessionEntry({ + cfg: runtimeCfg, + sessionKey: configuredDefaultResult.childSessionKey, + }); + expect(configuredDefaultEntry.acp?.runtimeOptions).toMatchObject({ + model: subagentModel, + thinking, + }); + + const primaryOnlyResult = await spawnAcpDirect( + { + task: "Reply with exactly LIVE-ACP-SPAWN-PRIMARY-DEFAULT-OK", + agentId: "codex-acp-primary-only", + mode: "run", + }, + { agentSessionKey: "agent:main:main" }, + ); + if (!isSpawnAcpAcceptedResult(primaryOnlyResult)) { + throw new Error( + `primary-only ACP spawn failed (${primaryOnlyResult.errorCode}): ${primaryOnlyResult.error}`, + ); + } + expect(isSpawnAcpAcceptedResult(primaryOnlyResult)).toBe(true); + sessionKeys.push(primaryOnlyResult.childSessionKey); + const primaryOnlyEntry = await waitForSessionEntry({ + cfg: runtimeCfg, + sessionKey: primaryOnlyResult.childSessionKey, + }); + expect(primaryOnlyEntry.acp?.runtimeOptions).toMatchObject({ + model: subagentModel, + thinking, + }); + expect(primaryOnlyEntry.acp?.runtimeOptions?.model).not.toBe("anthropic/claude-sonnet-4-6"); + } finally { + const runtimeCfg = getRuntimeConfig(); + for (const sessionKey of sessionKeys) { + await getAcpSessionManager() + .closeSession({ + cfg: runtimeCfg, + sessionKey, + reason: "live-acp-spawn-defaults-test-cleanup", + discardPersistentState: true, + clearMeta: true, + requireAcpSession: false, + }) + .catch(() => {}); + } + clearConfigCache(); + clearRuntimeConfigSnapshot(); + await server.close(); + await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (previous.configPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = previous.configPath; + } + if (previous.stateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previous.stateDir; + } + if (previous.token === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previous.token; + } + if (previous.port === undefined) { + delete process.env.OPENCLAW_GATEWAY_PORT; + } else { + process.env.OPENCLAW_GATEWAY_PORT = previous.port; + } + 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.codexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previous.codexHome; + } + } + }, + LIVE_TIMEOUT_MS + 120_000, + ); +});