From 5a5b2b17647a03167a92bd149dbf78cda6b048ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 15:05:29 +0100 Subject: [PATCH] test(acp): harden embedded bind live coverage --- docs/help/testing.md | 8 +-- scripts/test-live-acp-bind-docker.sh | 29 +++++---- src/gateway/gateway-acp-bind.live.test.ts | 71 +++++++++++++++++++-- src/plugins/bundled-plugin-metadata.test.ts | 6 ++ 4 files changed, 91 insertions(+), 23 deletions(-) diff --git a/docs/help/testing.md b/docs/help/testing.md index 3a6f3145253..08524bc9869 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -308,10 +308,10 @@ Notes: - Overrides: - `OPENCLAW_LIVE_ACP_BIND_AGENT=claude` - `OPENCLAW_LIVE_ACP_BIND_AGENT=codex` - - `OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND=/full/path/to/acpx` + - `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND='npx -y @agentclientprotocol/claude-agent-acp@'` - Notes: - This lane uses the gateway `chat.send` surface with admin-only synthetic originating-route fields so tests can attach message-channel context without pretending to deliver externally. - - When `OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND` is unset, the test uses the configured/bundled acpx command. If your harness auth depends on env vars from `~/.profile`, prefer a custom `acpx` command that preserves provider env. + - When `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND` is unset, the test uses the embedded `acpx` plugin's built-in agent registry for the selected ACP harness agent. Example: @@ -330,8 +330,8 @@ pnpm test:docker:live-acp-bind Docker notes: - The Docker runner lives at `scripts/test-live-acp-bind-docker.sh`. -- It sources `~/.profile`, copies the matching CLI auth home (`~/.claude` or `~/.codex`) into the container, installs `acpx` into a writable npm prefix, then installs the requested live CLI (`@anthropic-ai/claude-code` or `@openai/codex`) if missing. -- Inside Docker, the runner sets `OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND=$HOME/.npm-global/bin/acpx` so acpx keeps provider env vars from the sourced profile available to the child harness CLI. +- It sources `~/.profile`, copies the matching CLI auth home (`~/.claude` or `~/.codex`) into the container, then installs the requested live CLI (`@anthropic-ai/claude-code` or `@openai/codex`) if missing. +- Inside Docker, the runner relies on the embedded `acpx` plugin's built-in agent registry and the sourced profile env; use `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND` only when you need a custom harness command. ### Recommended live recipes diff --git a/scripts/test-live-acp-bind-docker.sh b/scripts/test-live-acp-bind-docker.sh index e2beac2488c..03f6a7179a9 100644 --- a/scripts/test-live-acp-bind-docker.sh +++ b/scripts/test-live-acp-bind-docker.sh @@ -10,12 +10,6 @@ WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}" PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-$HOME/.profile}" CLI_TOOLS_DIR="${OPENCLAW_DOCKER_CLI_TOOLS_DIR:-$HOME/.cache/openclaw/docker-cli-tools}" ACP_AGENT="${OPENCLAW_LIVE_ACP_BIND_AGENT:-claude}" -ACPX_VERSION="${OPENCLAW_DOCKER_ACPX_VERSION:-$(node -p "const pkg=require(process.argv[1]); process.stdout.write(String(pkg.dependencies?.acpx ?? ''))" "$ROOT_DIR/extensions/acpx/package.json")}" - -if [[ -z "$ACPX_VERSION" ]]; then - echo "Unable to resolve bundled ACPX version from extensions/acpx/package.json" >&2 - exit 1 -fi case "$ACP_AGENT" in claude) @@ -76,7 +70,7 @@ if ((${#AUTH_DIRS[@]} > 0)); then for auth_dir in "${AUTH_DIRS[@]}"; do host_path="$HOME/$auth_dir" if [[ -d "$host_path" ]]; then - EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/home/node/"$auth_dir":ro) + EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro) fi done fi @@ -93,7 +87,18 @@ read -r -d '' LIVE_TEST_CMD <<'EOF' || true set -euo pipefail [ -f "$HOME/.profile" ] && source "$HOME/.profile" || true export PATH="$HOME/.npm-global/bin:$PATH" +IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" +if ((${#auth_dirs[@]} > 0)); then + for auth_dir in "${auth_dirs[@]}"; do + [ -n "$auth_dir" ] || continue + if [ -d "/host-auth/$auth_dir" ]; then + mkdir -p "$HOME/$auth_dir" + cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" + chmod -R u+rwX "$HOME/$auth_dir" || true + fi + done +fi if ((${#auth_files[@]} > 0)); then for auth_file in "${auth_files[@]}"; do [ -n "$auth_file" ] || continue @@ -103,9 +108,6 @@ if ((${#auth_files[@]} > 0)); then fi done fi -if [ ! -x "$HOME/.npm-global/bin/acpx" ]; then - npm_config_prefix="$HOME/.npm-global" npm install -g "acpx@${OPENCLAW_DOCKER_ACPX_VERSION}" -fi agent="${OPENCLAW_LIVE_ACP_BIND_AGENT:-claude}" case "$agent" in claude) @@ -162,7 +164,7 @@ elif [ -d /app/dist/extensions ]; then export OPENCLAW_BUNDLED_PLUGINS_DIR=/app/dist/extensions fi cd "$tmp_dir" -export OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND="$HOME/.npm-global/bin/acpx" +export OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND="${OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND:-}" pnpm test:live src/gateway/gateway-acp-bind.live.test.ts EOF @@ -174,6 +176,7 @@ echo "==> Agent: $ACP_AGENT" echo "==> Auth dirs: ${AUTH_DIRS_CSV:-none}" echo "==> Auth files: ${AUTH_FILES_CSV:-none}" docker run --rm -t \ + -u node \ --entrypoint bash \ -e ANTHROPIC_API_KEY \ -e ANTHROPIC_API_KEY_OLD \ @@ -185,12 +188,12 @@ docker run --rm -t \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_VITEST_FS_MODULE_CACHE=0 \ - -e OPENCLAW_DOCKER_ACPX_VERSION="$ACPX_VERSION" \ + -e OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED="$AUTH_DIRS_CSV" \ -e OPENCLAW_DOCKER_AUTH_FILES_RESOLVED="$AUTH_FILES_CSV" \ -e OPENCLAW_LIVE_TEST=1 \ -e OPENCLAW_LIVE_ACP_BIND=1 \ -e OPENCLAW_LIVE_ACP_BIND_AGENT="$ACP_AGENT" \ - -e OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND="${OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND:-}" \ + -e OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND="${OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND:-}" \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ -v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \ diff --git a/src/gateway/gateway-acp-bind.live.test.ts b/src/gateway/gateway-acp-bind.live.test.ts index c1864fdb4dd..204272e549b 100644 --- a/src/gateway/gateway-acp-bind.live.test.ts +++ b/src/gateway/gateway-acp-bind.live.test.ts @@ -8,7 +8,12 @@ import { getAcpRuntimeBackend } from "../acp/runtime/registry.js"; import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; import { clearRuntimeConfigSnapshot, loadConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; +import { + pinActivePluginChannelRegistry, + releasePinnedPluginChannelRegistry, +} from "../plugins/runtime.js"; import { extractFirstTextBlock } from "../shared/chat-message-content.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { sleep } from "../utils.js"; import { GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GatewayClient } from "./client.js"; @@ -21,6 +26,22 @@ const describeLive = LIVE && ACP_BIND_LIVE ? describe : describe.skip; const CONNECT_TIMEOUT_MS = 90_000; const LIVE_TIMEOUT_MS = 240_000; +function createSlackCurrentConversationBindingRegistry() { + return createTestRegistry([ + { + pluginId: "slack", + source: "test", + plugin: { + id: "slack", + meta: { aliases: [] }, + conversationBindings: { + supportsCurrentConversationBinding: true, + }, + }, + }, + ]); +} + function normalizeAcpAgent(raw: string | undefined): "claude" | "codex" { const normalized = raw?.trim().toLowerCase(); if (normalized === "codex") { @@ -44,6 +65,11 @@ function extractAssistantTexts(messages: unknown[]): string[] { .filter((value): value is string => typeof value === "string" && value.trim().length > 0); } +function extractLastAssistantText(messages: unknown[]): string | null { + const texts = extractAssistantTexts(messages); + return texts.at(-1) ?? null; +} + function extractSpawnedAcpSessionKey(texts: string[]): string | null { for (const text of texts) { const match = text.match(/Spawned ACP session (\S+) \(/); @@ -320,7 +346,8 @@ describeLive("gateway live (ACP bind)", () => { skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, }; const liveAgent = normalizeAcpAgent(process.env.OPENCLAW_LIVE_ACP_BIND_AGENT); - const acpxCommand = process.env.OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND?.trim() || undefined; + const agentCommandOverride = + process.env.OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND?.trim() || undefined; const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-acp-bind-")); const tempStateDir = path.join(tempRoot, "state"); const tempConfigPath = path.join(tempRoot, "openclaw.json"); @@ -331,6 +358,7 @@ describeLive("gateway live (ACP bind)", () => { const conversationId = `user:${slackUserId}`; const accountId = "default"; const followupNonce = randomBytes(4).toString("hex").toUpperCase(); + const memoryNonce = randomBytes(4).toString("hex").toUpperCase(); clearRuntimeConfigSnapshot(); process.env.OPENCLAW_STATE_DIR = tempStateDir; @@ -343,6 +371,13 @@ describeLive("gateway live (ACP bind)", () => { const cfg = loadConfig(); const acpxEntry = cfg.plugins?.entries?.acpx; + const existingAgentOverrides = + typeof acpxEntry?.config === "object" && + acpxEntry.config && + typeof acpxEntry.config.agents === "object" && + acpxEntry.config.agents + ? acpxEntry.config.agents + : undefined; const nextCfg = { ...cfg, gateway: { @@ -373,10 +408,14 @@ describeLive("gateway live (ACP bind)", () => { ...acpxEntry?.config, permissionMode: "approve-all", nonInteractivePermissions: "deny", - ...(acpxCommand + ...(agentCommandOverride ? { - command: acpxCommand, - expectedVersion: "any", + agents: { + ...existingAgentOverrides, + [liveAgent]: { + command: agentCommandOverride, + }, + }, } : {}), }, @@ -402,6 +441,8 @@ describeLive("gateway live (ACP bind)", () => { timeoutMs: CONNECT_TIMEOUT_MS, }); logLiveStep("gateway websocket connected"); + const channelRegistry = createSlackCurrentConversationBindingRegistry(); + pinActivePluginChannelRegistry(channelRegistry); try { const { mainAssistantTexts, spawnedSessionKey } = await bindConversationAndWait({ @@ -421,21 +462,39 @@ describeLive("gateway live (ACP bind)", () => { client, sessionKey: originalSessionKey, idempotencyKey: `idem-followup-${randomUUID()}`, - message: `Please include the token ACP-BIND-${followupNonce} in your reply.`, + message: `Reply with exactly this token and nothing else: ACP-BIND-${followupNonce}`, originatingChannel: "slack", originatingTo: conversationId, originatingAccountId: accountId, }); logLiveStep("follow-up turn completed"); + await sendChatAndWait({ + client, + sessionKey: originalSessionKey, + idempotencyKey: `idem-memory-${randomUUID()}`, + message: + "Reply with exactly two uppercase tokens separated by a single space: " + + "first, the token from your immediately previous assistant reply; " + + `second, ACP-BIND-MEMORY-${memoryNonce}. No extra text.`, + originatingChannel: "slack", + originatingTo: conversationId, + originatingAccountId: accountId, + }); + logLiveStep("memory follow-up turn completed"); + const boundHistory = await client.request<{ messages?: unknown[] }>("chat.history", { sessionKey: spawnedSessionKey, - limit: 12, + limit: 16, }); const assistantTexts = extractAssistantTexts(boundHistory.messages ?? []); + const lastAssistantText = extractLastAssistantText(boundHistory.messages ?? []); expect(assistantTexts.join("\n\n")).toContain(`ACP-BIND-${followupNonce}`); + expect(lastAssistantText).toContain(`ACP-BIND-${followupNonce}`); + expect(lastAssistantText).toContain(`ACP-BIND-MEMORY-${memoryNonce}`); logLiveStep("bound session transcript contains follow-up token"); } finally { + releasePinnedPluginChannelRegistry(channelRegistry); clearRuntimeConfigSnapshot(); await client.stopAndWait({ timeoutMs: 2_000 }).catch(() => {}); await server.close(); diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 18952c69754..7f7a0dd99e8 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -105,6 +105,12 @@ describe("bundled plugin metadata", () => { ); }); + it("keeps config schemas on all bundled plugin manifests", () => { + for (const entry of listBundledPluginMetadata()) { + expect(entry.manifest.configSchema).toEqual(expect.any(Object)); + } + }); + it("prefers built generated paths when present and falls back to source paths", () => { const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-metadata-");