diff --git a/docs/help/testing.md b/docs/help/testing.md index 4930ee88b86..66cd8f91286 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -320,6 +320,49 @@ Notes: - For `claude-cli`, it installs the Linux `@anthropic-ai/claude-code` package into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`). - It copies `~/.claude` into the container when available, but on machines where Claude auth is backed by `ANTHROPIC_API_KEY`, it also preserves `ANTHROPIC_API_KEY` / `ANTHROPIC_API_KEY_OLD` for the child Claude CLI via `OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV`. +## Live: ACP bind smoke (`/acp spawn ... --bind here`) + +- Test: `src/gateway/gateway-acp-bind.live.test.ts` +- Goal: validate the real ACP conversation-bind flow with a live ACP agent: + - send `/acp spawn --bind here` + - bind a synthetic message-channel conversation in place + - send a normal follow-up on that same conversation + - verify the follow-up lands in the bound ACP session transcript +- Enable: + - `pnpm test:live src/gateway/gateway-acp-bind.live.test.ts` + - `OPENCLAW_LIVE_ACP_BIND=1` +- Defaults: + - ACP agent: `claude` + - Synthetic channel: Slack DM-style conversation context + - ACP backend: `acpx` +- Overrides: + - `OPENCLAW_LIVE_ACP_BIND_AGENT=claude` + - `OPENCLAW_LIVE_ACP_BIND_AGENT=codex` + - `OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND=/full/path/to/acpx` +- 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. + +Example: + +```bash +OPENCLAW_LIVE_ACP_BIND=1 \ + OPENCLAW_LIVE_ACP_BIND_AGENT=claude \ + pnpm test:live src/gateway/gateway-acp-bind.live.test.ts +``` + +Docker recipe: + +```bash +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. + ### Recommended live recipes Narrow, explicit allowlists are fastest and least flaky: @@ -457,6 +500,7 @@ These Docker runners split into two buckets: The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: - Direct models: `pnpm test:docker:live-models` (script: `scripts/test-live-models-docker.sh`) +- ACP bind smoke: `pnpm test:docker:live-acp-bind` (script: `scripts/test-live-acp-bind-docker.sh`) - CLI backend smoke: `pnpm test:docker:live-cli-backend` (script: `scripts/test-live-cli-backend-docker.sh`) - Gateway + dev agent: `pnpm test:docker:live-gateway` (script: `scripts/test-live-gateway-models-docker.sh`) - Open WebUI live smoke: `pnpm test:docker:openwebui` (script: `scripts/e2e/openwebui-docker.sh`) diff --git a/package.json b/package.json index 039fd97488a..88ebd23adf6 100644 --- a/package.json +++ b/package.json @@ -1097,6 +1097,7 @@ "test:docker:cleanup": "bash scripts/test-cleanup-docker.sh", "test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh", "test:docker:gateway-network": "bash scripts/e2e/gateway-network-docker.sh", + "test:docker:live-acp-bind": "bash scripts/test-live-acp-bind-docker.sh", "test:docker:live-cli-backend": "bash scripts/test-live-cli-backend-docker.sh", "test:docker:live-gateway": "bash scripts/test-live-gateway-models-docker.sh", "test:docker:live-models": "bash scripts/test-live-models-docker.sh", diff --git a/scripts/test-live-acp-bind-docker.sh b/scripts/test-live-acp-bind-docker.sh new file mode 100644 index 00000000000..1ce80a7f7af --- /dev/null +++ b/scripts/test-live-acp-bind-docker.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT_DIR/scripts/lib/live-docker-auth.sh" +IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}" +LIVE_IMAGE_NAME="${OPENCLAW_LIVE_IMAGE:-${IMAGE_NAME}-live}" +CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}" +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}" +# Keep in sync with extensions/acpx/src/config.ts ACPX_PINNED_VERSION. +ACPX_VERSION="${OPENCLAW_DOCKER_ACPX_VERSION:-0.3.1}" + +case "$ACP_AGENT" in + claude) + AUTH_PROVIDER="claude-cli" + CLI_PACKAGE="@anthropic-ai/claude-code" + CLI_BIN="claude" + ;; + codex) + AUTH_PROVIDER="codex-cli" + CLI_PACKAGE="@openai/codex" + CLI_BIN="codex" + ;; + *) + echo "Unsupported OPENCLAW_LIVE_ACP_BIND_AGENT: $ACP_AGENT (expected claude or codex)" >&2 + exit 1 + ;; +esac + +mkdir -p "$CLI_TOOLS_DIR" + +PROFILE_MOUNT=() +if [[ -f "$PROFILE_FILE" ]]; then + PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/node/.profile:ro) +fi + +AUTH_DIRS=() +if [[ -n "${OPENCLAW_DOCKER_AUTH_DIRS:-}" ]]; then + while IFS= read -r auth_dir; do + [[ -n "$auth_dir" ]] || continue + AUTH_DIRS+=("$auth_dir") + done < <(openclaw_live_collect_auth_dirs) +else + while IFS= read -r auth_dir; do + [[ -n "$auth_dir" ]] || continue + AUTH_DIRS+=("$auth_dir") + done < <(openclaw_live_collect_auth_dirs_from_csv "$AUTH_PROVIDER") +fi +AUTH_DIRS_CSV="$(openclaw_live_join_csv "${AUTH_DIRS[@]}")" + +EXTERNAL_AUTH_MOUNTS=() +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) + fi +done + +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" +if [ ! -x "$HOME/.npm-global/bin/acpx" ]; then + npm_config_prefix="$HOME/.npm-global" npm install -g "acpx@${OPENCLAW_DOCKER_ACPX_VERSION:-0.3.1}" +fi +agent="${OPENCLAW_LIVE_ACP_BIND_AGENT:-claude}" +case "$agent" in + claude) + if [ ! -x "$HOME/.npm-global/bin/claude" ]; then + npm_config_prefix="$HOME/.npm-global" npm install -g @anthropic-ai/claude-code + fi + claude auth status || true + ;; + codex) + if [ ! -x "$HOME/.npm-global/bin/codex" ]; then + npm_config_prefix="$HOME/.npm-global" npm install -g @openai/codex + fi + ;; + *) + echo "Unsupported OPENCLAW_LIVE_ACP_BIND_AGENT: $agent" >&2 + exit 1 + ;; +esac +tmp_dir="$(mktemp -d)" +cleanup() { + rm -rf "$tmp_dir" +} +trap cleanup EXIT +tar -C /src \ + --exclude=.git \ + --exclude=node_modules \ + --exclude=dist \ + --exclude=ui/dist \ + --exclude=ui/node_modules \ + -cf - . | tar -C "$tmp_dir" -xf - +ln -s /app/node_modules "$tmp_dir/node_modules" +ln -s /app/dist "$tmp_dir/dist" +if [ -d /app/dist-runtime/extensions ]; then + export OPENCLAW_BUNDLED_PLUGINS_DIR=/app/dist-runtime/extensions +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" +pnpm test:live src/gateway/gateway-acp-bind.live.test.ts +EOF + +echo "==> Build live-test image: $LIVE_IMAGE_NAME (target=build)" +docker build --target build -t "$LIVE_IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR" + +echo "==> Run ACP bind live test in Docker" +echo "==> Agent: $ACP_AGENT" +echo "==> Auth dirs: ${AUTH_DIRS_CSV:-none}" +docker run --rm -t \ + -u node \ + --entrypoint bash \ + -e ANTHROPIC_API_KEY \ + -e ANTHROPIC_API_KEY_OLD \ + -e OPENAI_API_KEY \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e HOME=/home/node \ + -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_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:-}" \ + -v "$ROOT_DIR":/src:ro \ + -v "$CONFIG_DIR":/home/node/.openclaw \ + -v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \ + -v "$CLI_TOOLS_DIR":/home/node/.npm-global \ + "${EXTERNAL_AUTH_MOUNTS[@]}" \ + "${PROFILE_MOUNT[@]}" \ + "$LIVE_IMAGE_NAME" \ + -lc "$LIVE_TEST_CMD" diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 585b0581f03..50474e36862 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -530,6 +530,19 @@ describe("buildGatewayConnectionDetails", () => { } }); + it("falls back to the default config loader when test deps drift", () => { + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); + resolveGatewayPort.mockReturnValue(18800); + __testing.setDepsForTests({ + loadConfig: {} as never, + }); + + const details = buildGatewayConnectionDetails(); + + expect(details.url).toBe("ws://127.0.0.1:18789"); + expect(details.urlSource).toBe("local loopback"); + }); + it("throws for insecure ws:// remote URLs (CWE-319)", () => { loadConfig.mockReturnValue({ gateway: { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 2e6ad805beb..001410a739e 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -6,6 +6,7 @@ import { resolveGatewayPort, resolveStateDir, } from "../config/config.js"; +import { loadConfig as loadConfigFromIo } from "../config/io.js"; import { resolveConfigPath as resolveConfigPathFromPaths, resolveGatewayPort as resolveGatewayPortFromPaths, @@ -96,6 +97,16 @@ const gatewayCallDeps = { ...defaultGatewayCallDeps, }; +function loadGatewayConfig(): OpenClawConfig { + const loadConfigFn = + typeof gatewayCallDeps.loadConfig === "function" + ? gatewayCallDeps.loadConfig + : typeof defaultGatewayCallDeps.loadConfig === "function" + ? defaultGatewayCallDeps.loadConfig + : loadConfigFromIo; + return loadConfigFn(); +} + function resolveGatewayStateDir(env: NodeJS.ProcessEnv): string { const resolveStateDirFn = typeof gatewayCallDeps.resolveStateDir === "function" @@ -129,7 +140,7 @@ export function buildGatewayConnectionDetails( } = {}, ): GatewayConnectionDetails { return buildGatewayConnectionDetailsWithResolvers(options, { - loadConfig: () => gatewayCallDeps.loadConfig(), + loadConfig: () => loadGatewayConfig(), resolveConfigPath: (env) => resolveGatewayConfigPath(env), resolveGatewayPort: (config, env) => resolveGatewayPortValue(config, env), }); @@ -270,7 +281,7 @@ function resolveGatewayCallTimeout(timeoutValue: unknown): { } function resolveGatewayCallContext(opts: CallGatewayBaseOptions): ResolvedGatewayCallContext { - const config = opts.config ?? gatewayCallDeps.loadConfig(); + const config = opts.config ?? loadGatewayConfig(); const configPath = opts.configPath ?? resolveGatewayConfigPath(process.env); const isRemoteMode = config.gateway?.mode === "remote"; const remote = isRemoteMode diff --git a/src/gateway/gateway-acp-bind.live.test.ts b/src/gateway/gateway-acp-bind.live.test.ts new file mode 100644 index 00000000000..7d368a7b570 --- /dev/null +++ b/src/gateway/gateway-acp-bind.live.test.ts @@ -0,0 +1,390 @@ +import { randomBytes, 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 { 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 { extractFirstTextBlock } from "../shared/chat-message-content.js"; +import { sleep } from "../utils.js"; +import { GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { GatewayClient } from "./client.js"; +import { startGatewayServer } from "./server.js"; + +const LIVE = isLiveTestEnabled(); +const ACP_BIND_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_ACP_BIND); +const describeLive = LIVE && ACP_BIND_LIVE ? describe : describe.skip; + +const CONNECT_TIMEOUT_MS = 90_000; +const LIVE_TIMEOUT_MS = 240_000; + +function normalizeAcpAgent(raw: string | undefined): "claude" | "codex" { + const normalized = raw?.trim().toLowerCase(); + if (normalized === "codex") { + return "codex"; + } + return "claude"; +} + +function extractAssistantTexts(messages: unknown[]): string[] { + return messages + .map((entry) => { + if (!entry || typeof entry !== "object") { + return undefined; + } + const role = (entry as { role?: unknown }).role; + if (role !== "assistant") { + return undefined; + } + return extractFirstTextBlock(entry); + }) + .filter((value): value is string => typeof value === "string" && value.trim().length > 0); +} + +function extractSpawnedAcpSessionKey(texts: string[]): string | null { + for (const text of texts) { + const match = text.match(/Spawned ACP session (\S+) \(/); + if (match?.[1]) { + return match[1]; + } + } + return null; +} + +async function getFreeGatewayPort(): Promise { + const { getFreePortBlockWithPermissionFallback } = await import("../test-utils/ports.js"); + return await getFreePortBlockWithPermissionFallback({ + offsets: [0, 1, 2, 4], + fallbackBase: 41_000, + }); +} + +function logLiveStep(message: string): void { + console.info(`[live-acp-bind] ${message}`); +} + +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 connectClient(params: { url: string; token: string; timeoutMs?: number }) { + const timeoutMs = params.timeoutMs ?? CONNECT_TIMEOUT_MS; + return await new Promise((resolve, reject) => { + let done = false; + const finish = (result: { client?: GatewayClient; error?: Error }) => { + if (done) { + return; + } + done = true; + clearTimeout(connectTimeout); + if (result.error) { + reject(result.error); + return; + } + resolve(result.client as GatewayClient); + }; + + const client = new GatewayClient({ + url: params.url, + token: params.token, + clientName: GATEWAY_CLIENT_NAMES.TEST, + clientVersion: "dev", + mode: "test", + requestTimeoutMs: timeoutMs, + connectChallengeTimeoutMs: timeoutMs, + onHelloOk: () => finish({ client }), + onConnectError: (error) => finish({ error }), + onClose: (code, reason) => + finish({ error: new Error(`gateway closed during connect (${code}): ${reason}`) }), + }); + + const connectTimeout = setTimeout( + () => finish({ error: new Error("gateway connect timeout") }), + timeoutMs, + ); + connectTimeout.unref(); + client.start(); + }); +} + +async function waitForAcpBackendHealthy(timeoutMs = 60_000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const backend = getAcpRuntimeBackend("acpx"); + if (backend && (!backend.healthy || backend.healthy())) { + return; + } + await sleep(250); + } + throw new Error("timed out waiting for the acpx runtime backend to become healthy"); +} + +async function waitForAgentRunOk( + client: GatewayClient, + runId: string, + timeoutMs = LIVE_TIMEOUT_MS, +) { + const result = await client.request<{ status?: string }>( + "agent.wait", + { + runId, + timeoutMs, + }, + { + timeoutMs: timeoutMs + 5_000, + }, + ); + if (result?.status !== "ok") { + throw new Error(`agent.wait failed for ${runId}: status=${String(result?.status)}`); + } +} + +async function sendChatAndWait(params: { + client: GatewayClient; + sessionKey: string; + idempotencyKey: string; + message: string; + originatingChannel: string; + originatingTo: string; + originatingAccountId: string; +}) { + const started = await params.client.request<{ runId?: string; status?: string }>("chat.send", { + sessionKey: params.sessionKey, + message: params.message, + idempotencyKey: params.idempotencyKey, + originatingChannel: params.originatingChannel, + originatingTo: params.originatingTo, + originatingAccountId: params.originatingAccountId, + }); + if (started?.status !== "started" || typeof started.runId !== "string") { + throw new Error(`chat.send did not start correctly: ${JSON.stringify(started)}`); + } + await waitForAgentRunOk(params.client, started.runId); +} + +describeLive("gateway live (ACP bind)", () => { + it( + "binds a synthetic Slack DM conversation to a live ACP session and reroutes the next turn", + 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, + }; + const liveAgent = normalizeAcpAgent(process.env.OPENCLAW_LIVE_ACP_BIND_AGENT); + const acpxCommand = process.env.OPENCLAW_LIVE_ACP_BIND_ACPX_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"); + const port = await getFreeGatewayPort(); + const token = `test-${randomUUID()}`; + const originalSessionKey = "main"; + const slackUserId = `U${randomUUID().replace(/-/g, "").slice(0, 10).toUpperCase()}`; + const conversationId = `user:${slackUserId}`; + const accountId = "default"; + const followupNonce = randomBytes(4).toString("hex").toUpperCase(); + + clearRuntimeConfigSnapshot(); + 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); + + const cfg = loadConfig(); + const acpxEntry = cfg.plugins?.entries?.acpx; + const nextCfg = { + ...cfg, + gateway: { + ...cfg.gateway, + mode: "local", + bind: "loopback", + port, + }, + acp: { + ...cfg.acp, + enabled: true, + backend: "acpx", + defaultAgent: liveAgent, + allowedAgents: Array.from(new Set([...(cfg.acp?.allowedAgents ?? []), liveAgent])), + dispatch: { + ...cfg.acp?.dispatch, + enabled: true, + }, + }, + plugins: { + ...cfg.plugins, + entries: { + ...cfg.plugins?.entries, + acpx: { + ...acpxEntry, + enabled: true, + config: { + ...acpxEntry?.config, + permissionMode: "approve-all", + nonInteractivePermissions: "deny", + ...(acpxCommand + ? { + command: acpxCommand, + expectedVersion: "any", + } + : {}), + }, + }, + }, + }, + }; + await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`); + process.env.OPENCLAW_CONFIG_PATH = tempConfigPath; + + logLiveStep(`starting gateway on port ${String(port)}`); + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "token", token }, + controlUiEnabled: false, + }); + logLiveStep("gateway startup returned"); + await waitForGatewayPort({ host: "127.0.0.1", port, timeoutMs: CONNECT_TIMEOUT_MS }); + logLiveStep("gateway port is reachable"); + const client = await connectClient({ + url: `ws://127.0.0.1:${port}`, + token, + timeoutMs: CONNECT_TIMEOUT_MS, + }); + logLiveStep("gateway websocket connected"); + + try { + logLiveStep("waiting for acpx backend health"); + await waitForAcpBackendHealthy(); + logLiveStep("acpx backend healthy"); + + await sendChatAndWait({ + client, + sessionKey: originalSessionKey, + idempotencyKey: `idem-bind-${randomUUID()}`, + message: `/acp spawn ${liveAgent} --bind here`, + originatingChannel: "slack", + originatingTo: conversationId, + originatingAccountId: accountId, + }); + logLiveStep("bind command completed"); + + const mainHistory = await client.request<{ messages?: unknown[] }>("chat.history", { + sessionKey: originalSessionKey, + limit: 12, + }); + const mainAssistantTexts = extractAssistantTexts(mainHistory.messages ?? []); + const spawnedSessionKey = extractSpawnedAcpSessionKey(mainAssistantTexts); + expect(mainAssistantTexts.join("\n\n")).toContain("Bound this conversation to"); + expect(spawnedSessionKey).toMatch(new RegExp(`^agent:${liveAgent}:acp:`)); + if (!spawnedSessionKey) { + throw new Error("bind response did not expose the spawned ACP session key"); + } + logLiveStep(`binding announced for session ${spawnedSessionKey ?? "missing"}`); + + await sendChatAndWait({ + client, + sessionKey: originalSessionKey, + idempotencyKey: `idem-followup-${randomUUID()}`, + message: `Please include the token ACP-BIND-${followupNonce} in your reply.`, + originatingChannel: "slack", + originatingTo: conversationId, + originatingAccountId: accountId, + }); + logLiveStep("follow-up turn completed"); + + const boundHistory = await client.request<{ messages?: unknown[] }>("chat.history", { + sessionKey: spawnedSessionKey, + limit: 12, + }); + const assistantTexts = extractAssistantTexts(boundHistory.messages ?? []); + expect(assistantTexts.join("\n\n")).toContain(`ACP-BIND-${followupNonce}`); + logLiveStep("bound session transcript contains follow-up token"); + } finally { + clearRuntimeConfigSnapshot(); + await client.stopAndWait({ timeoutMs: 2_000 }).catch(() => {}); + await server.close(); + await fs.rm(tempRoot, { recursive: true, force: true }); + 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; + } + } + }, + LIVE_TIMEOUT_MS + 180_000, + ); +});