mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 10:50:58 +00:00
test(gateway): add live docker ACP bind coverage
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -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",
|
||||
|
||||
140
scripts/test-live-acp-bind-docker.sh
Normal file
140
scripts/test-live-acp-bind-docker.sh
Normal 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"
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
390
src/gateway/gateway-acp-bind.live.test.ts
Normal file
390
src/gateway/gateway-acp-bind.live.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user