test(gateway): add live docker ACP bind coverage

This commit is contained in:
Peter Steinberger
2026-03-28 05:22:39 +00:00
parent 19e8e7190b
commit 6a556c6851
6 changed files with 601 additions and 2 deletions

View File

@@ -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 <agent> --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`)

View File

@@ -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",

View File

@@ -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"

View File

@@ -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: {

View File

@@ -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

View File

@@ -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<number> {
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<void> {
const timeoutMs = params.timeoutMs ?? CONNECT_TIMEOUT_MS;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const connected = await new Promise<boolean>((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<GatewayClient>((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<void> {
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,
);
});