mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(worker): honor full access on paired node sessions (#129537)
* fix(worker): honor full access on paired node sessions * test(worker): preserve narrowed operator in live proof * test(vitest): assign Codex startup retry to its owner shard
This commit is contained in:
committed by
GitHub
parent
59e55d2dfe
commit
a6ebad9964
+17
-17
@@ -8,23 +8,23 @@ title: "Worker"
|
||||
|
||||
# `openclaw worker`
|
||||
|
||||
`openclaw worker` is the restricted runtime entry point for a cloud worker
|
||||
orchestrator to launch inside a prepared worker environment. It is not a
|
||||
general-purpose command for manual worker registration.
|
||||
`openclaw worker` is the restricted runtime entry point for a Gateway-owned
|
||||
launcher to start inside a prepared cloud or paired-node worker environment.
|
||||
It is not a general-purpose command for manual worker registration.
|
||||
|
||||
The gateway installs the matching OpenClaw bundle and opens the host-key-pinned
|
||||
reverse SSH tunnel. The worker launcher starts this command with a prepared
|
||||
assignment. The command connects through the tunnel-forwarded local socket and
|
||||
admits as the dedicated `worker` role.
|
||||
The Gateway installs the matching OpenClaw bundle through the enrolled node's
|
||||
authenticated connection. The worker launcher starts this command with a
|
||||
prepared assignment, and the worker connects back to the Gateway over its own
|
||||
authenticated outbound WebSocket as the dedicated `worker` role.
|
||||
|
||||
## Launch contract
|
||||
|
||||
The command reads exactly one bounded JSON launch envelope from standard input.
|
||||
The envelope carries the local socket location, minted worker credential, bundle
|
||||
and protocol identity, owner epoch, the single assigned session and turn, and the
|
||||
exact worker-local tool names authorized for that turn. The Gateway resolves this
|
||||
final tool set from current policy before handoff; raw config and scheduled-owner
|
||||
identity never enter the worker envelope.
|
||||
The envelope carries the Gateway worker endpoint, minted worker credential,
|
||||
bundle and protocol identity, owner epoch, the single assigned session and turn,
|
||||
and the exact worker-local tool names authorized for that turn. The Gateway
|
||||
resolves this final tool set from current policy before handoff; raw config and
|
||||
scheduled-owner identity never enter the worker envelope.
|
||||
The credential is never accepted through command-line arguments, and this page
|
||||
intentionally provides no credential or hand-authored envelope example.
|
||||
|
||||
@@ -57,8 +57,8 @@ managed-worktree session through the Gateway, while a worker process cannot
|
||||
dispatch itself or another worker.
|
||||
|
||||
The prepared assignment carries the transcript context, accepted base leaf,
|
||||
commit sequence, and live-event cursor. On a tunnel reconnect, the process
|
||||
re-admits with the same credential and owner epoch, retains the accepted
|
||||
commit sequence, and live-event cursor. On a worker WebSocket reconnect, the
|
||||
process re-admits with the same credential and owner epoch, retains the accepted
|
||||
transcript base, replays its unacknowledged live-event tail, and reattaches an
|
||||
in-flight inference turn with the same identity. The terminal inference message
|
||||
is authoritative if streamed deltas were missed. A superseding owner epoch
|
||||
@@ -70,9 +70,9 @@ duplicate commit is produced; any still-uncommitted in-memory tail from that
|
||||
run is lost. Relaunch belongs to the milestone-3 placement owner, which must
|
||||
create a fresh assignment from the gateway's authoritative transcript and
|
||||
commit ledger. Likewise, a gateway process restart terminates a pending
|
||||
inference turn with a provider error; only a tunnel or worker WebSocket
|
||||
reconnect can reattach to an active same-process inference stream.
|
||||
inference turn with a provider error; only a worker WebSocket reconnect can
|
||||
reattach to an active same-process inference stream.
|
||||
|
||||
See [Gateway protocol](/gateway/protocol#worker-role-and-closed-protocol) for the
|
||||
closed worker RPC surface and [Cloud workers plan](/plan/cloud-workers) for the
|
||||
closed worker RPC surface and [Cloud workers](/gateway/cloud-workers) for the
|
||||
architecture and security model.
|
||||
|
||||
@@ -179,6 +179,7 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams)
|
||||
}),
|
||||
applyPatchWorkspaceOnly: permissionToolPolicy?.applyPatchWorkspaceOnly ?? true,
|
||||
execDefaults: {
|
||||
bypassHostApprovalFloors: permissionToolPolicy?.bypassHostApprovalFloors,
|
||||
host: "gateway",
|
||||
mode: permissionToolPolicy?.execMode ?? "full",
|
||||
security: "full",
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
import { createDeferred, withTestTimeout } from "../../test/helpers/promise.js";
|
||||
import { createOperationalRunInstanceRef } from "../agents/admitted-run-context.js";
|
||||
import { listRunningSessions } from "../agents/bash-process-registry.js";
|
||||
import { saveExecApprovals, type ExecApprovalsFile } from "../infra/exec-approvals.js";
|
||||
import { buildWorkerConnectParams, type WorkerLaunchDescriptor } from "./launch-descriptor.js";
|
||||
import { WORKER_PROVIDER_REPLAY_LOCAL_RETRY_MESSAGE } from "./transcript-message.js";
|
||||
import { WorkerAdmissionDeadlineExceededError } from "./worker-connection-contract.js";
|
||||
@@ -122,6 +123,7 @@ type WorkerDoneMessage = Extract<WorkerInferenceTerminalOutcome, { type: "done"
|
||||
|
||||
type FakeGatewayOptions = {
|
||||
admissionFailure?: "gateway-unavailable" | "invalid-credential" | "owner-epoch-mismatch";
|
||||
execApprovals?: ExecApprovalsFile;
|
||||
inferencePlans?: InferencePlan[];
|
||||
outageOnInferenceCancel?: boolean;
|
||||
ignoreFirstAdmission?: boolean;
|
||||
@@ -512,6 +514,9 @@ class FakeWorkerGateway {
|
||||
private handleInference(socket: WebSocket, frame: WorkerInferenceStartRequestFrame): void {
|
||||
this.methods.push(frame.method);
|
||||
this.inferenceRequests.push(structuredClone(frame.params));
|
||||
if (this.inferencePlanIndex === 0 && this.options.execApprovals) {
|
||||
saveExecApprovals(this.options.execApprovals);
|
||||
}
|
||||
this.inferenceStarted.resolve();
|
||||
if (this.options.silenceFirstInference && !this.droppedInference) {
|
||||
this.droppedInference = true;
|
||||
@@ -1467,7 +1472,18 @@ describe("worker runtime", () => {
|
||||
},
|
||||
{ mode: "full" as const, omittedTools: [], denial: null },
|
||||
])("applies the $mode worker permission clamp", async ({ mode, omittedTools, denial }) => {
|
||||
const { gateway, workspaceDir, launch } = await setup({ inferencePlans: ["tool", "text"] });
|
||||
const { gateway, workspaceDir, launch } = await setup({
|
||||
inferencePlans: ["tool", "text"],
|
||||
...(mode === "full"
|
||||
? {
|
||||
execApprovals: {
|
||||
version: 1,
|
||||
defaults: { security: "full", ask: "always" },
|
||||
agents: {},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
launch.assignment.permissionMode = mode;
|
||||
launch.assignment.workerContainmentRoot = workspaceDir;
|
||||
|
||||
@@ -1491,6 +1507,8 @@ describe("worker runtime", () => {
|
||||
await expect(readFile(path.join(workspaceDir, "local-proof.txt"), "utf8")).resolves.toBe(
|
||||
"worker-local",
|
||||
);
|
||||
expect(toolResult).not.toMatch(/approval_required|approval-pending/iu);
|
||||
expect(gateway.methods.some((method) => method.includes("approval"))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { buildControlUiSessionPath } from "@openclaw/session-url-contract";
|
||||
import type { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import type { Browser, BrowserContext, Page } from "playwright";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { startQaMockOpenAiServer } from "../../../../extensions/qa-lab/api.js";
|
||||
import { NODE_WORKER_SUPERVISOR_LAUNCH_COMMAND } from "../../../../src/infra/node-commands.js";
|
||||
import { resolveNodeWorkerContainerEngine } from "../../../../src/node-host/node-worker-container-engine.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
|
||||
import { MODEL_REF, PROOF_TIMEOUT_MS } from "./cloud-worker-midturn-loss-fixture.js";
|
||||
import {
|
||||
closeWireServer,
|
||||
connectWireClient,
|
||||
createPairedNodeWorkerHost,
|
||||
createPublishedWireWorkspace,
|
||||
startPairedNodeWorkerGateway,
|
||||
type PairedNodeWorkerHost,
|
||||
type WireGateway,
|
||||
wireMessageText,
|
||||
} from "./paired-node-worker-wire-fixture.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CONTAINER_WIRE_ENABLED = process.env.OPENCLAW_DOCKER_NODE_WORKER_E2E === "1";
|
||||
const CONTROL_UI_PROOF_ENABLED = process.env.OPENCLAW_DOCKER_NODE_WORKER_UI_PROOF === "1";
|
||||
const CONTAINER_IMAGE = process.env.OPENCLAW_DOCKER_NODE_WORKER_IMAGE ?? "node:24-bookworm";
|
||||
const CONTAINER_GATEWAY_HOST =
|
||||
process.env.OPENCLAW_DOCKER_NODE_WORKER_GATEWAY_HOST ?? "host.docker.internal";
|
||||
const SESSION_KEY = "agent:qa:node-worker-container-wire";
|
||||
const EXEC_MARKER = "NODE_WORKER_CONTAINER_YOLO_OK";
|
||||
const EXEC_FILE = "node-worker-container-yolo.txt";
|
||||
const EXEC_COMMAND = `test -f /.dockerenv && printf ${EXEC_MARKER} > ${EXEC_FILE} && sleep 1`;
|
||||
const PROMPT = `Tool progress QA check: call the exec tool exactly once with this exact command before answering: \`${EXEC_COMMAND}\`. After that exec command completes or fails, reply exactly \`${EXEC_MARKER}\`.`;
|
||||
const CONTAINER_INSPECT_FORMAT =
|
||||
'{"mounts":{{json .Mounts}},"image":{{json .Config.Image}},"state":{{json .State.Status}},"labels":{{json .Config.Labels}}}';
|
||||
|
||||
type ObservedWorkerContainer = {
|
||||
id: string;
|
||||
image: string;
|
||||
state: string;
|
||||
labels: Record<string, string>;
|
||||
mounts: Array<{ Source: string; Destination: string; RW: boolean }>;
|
||||
};
|
||||
|
||||
type ControlUiProof = {
|
||||
artifactDir: string;
|
||||
browser: Browser;
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
};
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
async function dockerOutput(args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("docker", args, {
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function observeWorkerContainer(launchId: string): Promise<ObservedWorkerContainer> {
|
||||
const encodedLaunch = Buffer.from(launchId).toString("base64url");
|
||||
let observed: ObservedWorkerContainer | undefined;
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const id = await dockerOutput([
|
||||
"ps",
|
||||
"--all",
|
||||
"--no-trunc",
|
||||
"--filter",
|
||||
`label=openclaw.node-worker.launch=${encodedLaunch}`,
|
||||
"--format",
|
||||
"{{.ID}}",
|
||||
]);
|
||||
expect(id).toMatch(/^[a-f0-9]{64}$/u);
|
||||
const metadata = JSON.parse(
|
||||
await dockerOutput(["inspect", "--format", CONTAINER_INSPECT_FORMAT, id]),
|
||||
) as Omit<ObservedWorkerContainer, "id">;
|
||||
expect(["created", "running"]).toContain(metadata.state);
|
||||
observed = { id, ...metadata };
|
||||
},
|
||||
{ timeout: 30_000, interval: 50 },
|
||||
);
|
||||
if (!observed) {
|
||||
throw new Error("Docker worker container was never observed");
|
||||
}
|
||||
return observed;
|
||||
}
|
||||
|
||||
async function startControlUiProof(gateway: WireGateway): Promise<ControlUiProof> {
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const response = await fetch(`${gateway.baseUrl}/new`);
|
||||
const body = await response.text();
|
||||
expect({ status: response.status, body: body.slice(0, 160) }).toMatchObject({ status: 200 });
|
||||
expect(response.headers.get("content-type")).toContain("text/html");
|
||||
},
|
||||
{ timeout: 60_000, interval: 250 },
|
||||
);
|
||||
const { chromium } = await import("playwright");
|
||||
const artifactDir = path.resolve(
|
||||
process.env.OPENCLAW_DOCKER_NODE_WORKER_ARTIFACT_DIR ??
|
||||
".artifacts/control-ui-e2e/node-worker-container-wire",
|
||||
);
|
||||
await fs.mkdir(artifactDir, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } },
|
||||
});
|
||||
await context.addInitScript(
|
||||
({ gatewayUrl, token }) => {
|
||||
Object.defineProperty(globalThis, "__OPENCLAW_NATIVE_CONTROL_AUTH__", {
|
||||
configurable: true,
|
||||
value: { gatewayUrl, token },
|
||||
});
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => {
|
||||
const mask = document.createElement("style");
|
||||
mask.textContent = '[data-chat-model-select="true"] { visibility: hidden !important; }';
|
||||
document.head.append(mask);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
},
|
||||
{ gatewayUrl: gateway.wsUrl, token: gateway.token },
|
||||
);
|
||||
return { artifactDir, browser, context, page: await context.newPage() };
|
||||
}
|
||||
|
||||
async function captureControlUiProof(proof: ControlUiProof, name: string): Promise<void> {
|
||||
await proof.page.screenshot({ path: path.join(proof.artifactDir, `${name}.png`) });
|
||||
}
|
||||
|
||||
describe.runIf(CONTAINER_WIRE_ENABLED)("node worker real Docker wire", () => {
|
||||
it(
|
||||
"runs a full-access remote turn in Docker without producing approval requests",
|
||||
{ timeout: PROOF_TIMEOUT_MS + 120_000 },
|
||||
async () => {
|
||||
const root = tempDirs.make("openclaw-node-worker-container-wire-");
|
||||
const provider = await startQaMockOpenAiServer({ modelRefs: [MODEL_REF] });
|
||||
const published = await createPublishedWireWorkspace(root);
|
||||
const engine = await resolveNodeWorkerContainerEngine();
|
||||
const approvalEvents: string[] = [];
|
||||
let gateway: WireGateway | undefined;
|
||||
let operator: GatewayClient | undefined;
|
||||
let workerNode: PairedNodeWorkerHost | undefined;
|
||||
let observedContainer: Promise<ObservedWorkerContainer> | undefined;
|
||||
let controlUiProof: ControlUiProof | undefined;
|
||||
let browserRunId: string | undefined;
|
||||
let launchId: string | undefined;
|
||||
|
||||
try {
|
||||
expect(engine.id).toBe("docker");
|
||||
gateway = await startPairedNodeWorkerGateway({
|
||||
providerBaseUrl: provider.baseUrl,
|
||||
fullAccess: true,
|
||||
useRepoCli: false,
|
||||
...(CONTROL_UI_PROOF_ENABLED
|
||||
? { controlUiEnabled: true, workspaceDir: published.source }
|
||||
: {}),
|
||||
});
|
||||
operator = await connectWireClient({
|
||||
gateway,
|
||||
role: "operator",
|
||||
identity: null,
|
||||
includeApprovals: true,
|
||||
onEvent: (event) => {
|
||||
if (event.event.endsWith(".approval.requested")) {
|
||||
approvalEvents.push(event.event);
|
||||
}
|
||||
if (event.event === "chat") {
|
||||
const payload = event.payload as
|
||||
| { runId?: unknown; sessionKey?: unknown }
|
||||
| undefined;
|
||||
if (payload?.sessionKey === SESSION_KEY && typeof payload.runId === "string") {
|
||||
browserRunId = payload.runId;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const initialApprovals = await operator.request<{ hash: string }>("exec.approvals.get", {});
|
||||
await operator.request("exec.approvals.set", {
|
||||
baseHash: initialApprovals.hash,
|
||||
file: {
|
||||
version: 1,
|
||||
defaults: { security: "allowlist", ask: "always", askFallback: "deny" },
|
||||
},
|
||||
});
|
||||
|
||||
const workerGatewayUrl = new URL(gateway.wsUrl);
|
||||
workerGatewayUrl.hostname = CONTAINER_GATEWAY_HOST;
|
||||
workerNode = await createPairedNodeWorkerHost({
|
||||
gateway,
|
||||
operator,
|
||||
root,
|
||||
containerEngine: engine,
|
||||
containerImage: CONTAINER_IMAGE,
|
||||
workerGatewayUrl: workerGatewayUrl.toString(),
|
||||
workerEnv: { OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1" },
|
||||
onInvoke: (frame) => {
|
||||
if (frame.command !== NODE_WORKER_SUPERVISOR_LAUNCH_COMMAND || !frame.paramsJSON) {
|
||||
return;
|
||||
}
|
||||
launchId = (JSON.parse(frame.paramsJSON) as { launchId?: string }).launchId;
|
||||
if (launchId) {
|
||||
observedContainer = observeWorkerContainer(launchId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (CONTROL_UI_PROOF_ENABLED) {
|
||||
controlUiProof = await startControlUiProof(gateway);
|
||||
await controlUiProof.page.goto(`${gateway.baseUrl}/new`);
|
||||
const where = controlUiProof.page.locator("#new-session-where-trigger");
|
||||
await where.waitFor({ state: "visible", timeout: 60_000 });
|
||||
await where.click();
|
||||
const device = controlUiProof.page.locator(
|
||||
`[data-value="device:${workerNode.identity.deviceId}"]`,
|
||||
);
|
||||
await device.waitFor({ state: "visible", timeout: 30_000 });
|
||||
expect(await device.isEnabled()).toBe(true);
|
||||
await captureControlUiProof(controlUiProof, "01-remote-device-available");
|
||||
await device.click();
|
||||
await expect
|
||||
.poll(() => where.getAttribute("data-device-id"))
|
||||
.toBe(workerNode.identity.deviceId);
|
||||
await captureControlUiProof(controlUiProof, "02-remote-device-selected");
|
||||
}
|
||||
|
||||
await operator.request("sessions.create", {
|
||||
key: SESSION_KEY,
|
||||
agentId: "qa",
|
||||
worktree: true,
|
||||
worktreeName: "node-worker-container-wire",
|
||||
worktreeBaseRef: "main",
|
||||
cwd: published.source,
|
||||
permissionMode: controlUiProof ? "workspace" : "full",
|
||||
});
|
||||
const dispatched = (await gateway.call(
|
||||
"sessions.dispatch",
|
||||
{ key: SESSION_KEY, deviceId: workerNode.identity.deviceId },
|
||||
{ timeoutMs: PROOF_TIMEOUT_MS },
|
||||
)) as { placement?: { state?: string; remoteWorkspaceDir?: string } };
|
||||
expect(dispatched.placement).toMatchObject({ state: "active" });
|
||||
const remoteWorkspaceDir = dispatched.placement?.remoteWorkspaceDir;
|
||||
expect(remoteWorkspaceDir).toBeTruthy();
|
||||
|
||||
if (controlUiProof) {
|
||||
const sessionPath = buildControlUiSessionPath({
|
||||
namespace: "chat",
|
||||
sessionKey: SESSION_KEY,
|
||||
fallbackAgentId: "qa",
|
||||
});
|
||||
await controlUiProof.page.goto(`${gateway.baseUrl}${sessionPath}`);
|
||||
const permission = controlUiProof.page.locator('[data-chat-permission-select="true"]');
|
||||
await permission.waitFor({ state: "visible", timeout: 60_000 });
|
||||
await permission.click();
|
||||
await controlUiProof.page.locator('[data-chat-permission-option="full"]').click();
|
||||
await expect.poll(() => permission.getAttribute("data-chat-select-value")).toBe("full");
|
||||
await captureControlUiProof(controlUiProof, "03-full-access-selected");
|
||||
|
||||
await controlUiProof.page.locator(".agent-chat__composer-combobox textarea").fill(PROMPT);
|
||||
await controlUiProof.page.getByRole("button", { name: "Send message" }).click();
|
||||
const activeOperator = operator;
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
expect(launchId).toBeTruthy();
|
||||
expect(browserRunId).toBeTruthy();
|
||||
const history = await activeOperator.request<{ messages?: unknown[] }>(
|
||||
"chat.history",
|
||||
{
|
||||
sessionKey: SESSION_KEY,
|
||||
limit: 20,
|
||||
},
|
||||
);
|
||||
expect(
|
||||
history.messages?.some(
|
||||
(message) =>
|
||||
(message as { role?: unknown }).role === "assistant" &&
|
||||
wireMessageText(message).includes(EXEC_MARKER),
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
{ timeout: PROOF_TIMEOUT_MS, interval: 250 },
|
||||
);
|
||||
const completed = await operator.request<{ status?: string }>(
|
||||
"agent.wait",
|
||||
{ runId: browserRunId, timeoutMs: PROOF_TIMEOUT_MS },
|
||||
{ timeoutMs: PROOF_TIMEOUT_MS + 5_000 },
|
||||
);
|
||||
if (completed.status !== "ok") {
|
||||
throw new Error(
|
||||
`browser container worker turn failed: ${JSON.stringify(completed)}\n${gateway.logs().slice(-12_000)}`,
|
||||
);
|
||||
}
|
||||
await controlUiProof.page
|
||||
.locator(".chat-group.assistant")
|
||||
.getByText(EXEC_MARKER, { exact: true })
|
||||
.last()
|
||||
.waitFor({ state: "visible", timeout: PROOF_TIMEOUT_MS });
|
||||
expect(
|
||||
await controlUiProof.page
|
||||
.locator("[data-approval-id], .exec-approval-modal-stack")
|
||||
.count(),
|
||||
).toBe(0);
|
||||
await captureControlUiProof(controlUiProof, "04-full-access-completed-without-alerts");
|
||||
} else {
|
||||
const runId = `node-worker-container-yolo-${Date.now()}`;
|
||||
await expect(
|
||||
operator.request("chat.send", {
|
||||
sessionKey: SESSION_KEY,
|
||||
message: PROMPT,
|
||||
deliver: false,
|
||||
idempotencyKey: runId,
|
||||
}),
|
||||
).resolves.toMatchObject({ runId, status: "started" });
|
||||
const completed = await operator.request<{ status?: string }>(
|
||||
"agent.wait",
|
||||
{ runId, timeoutMs: PROOF_TIMEOUT_MS },
|
||||
{ timeoutMs: PROOF_TIMEOUT_MS + 5_000 },
|
||||
);
|
||||
if (completed.status !== "ok") {
|
||||
throw new Error(
|
||||
`container worker turn failed: ${JSON.stringify(completed)}\n${gateway.logs().slice(-12_000)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
expect(launchId).toBeTruthy();
|
||||
expect(observedContainer).toBeTruthy();
|
||||
const container = await observedContainer!;
|
||||
expect(container.image).toBe(CONTAINER_IMAGE);
|
||||
expect(container.mounts).toHaveLength(2);
|
||||
expect(container.mounts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
Source: remoteWorkspaceDir,
|
||||
Destination: remoteWorkspaceDir,
|
||||
RW: true,
|
||||
}),
|
||||
);
|
||||
expect(container.mounts.filter((mount) => !mount.RW)).toHaveLength(1);
|
||||
expect(container.labels["openclaw.node-worker.launch"]).toBe(
|
||||
Buffer.from(launchId!).toString("base64url"),
|
||||
);
|
||||
await expect(fs.readFile(path.join(remoteWorkspaceDir!, EXEC_FILE), "utf8")).resolves.toBe(
|
||||
EXEC_MARKER,
|
||||
);
|
||||
|
||||
const described = (await gateway.call("sessions.describe", { key: SESSION_KEY })) as {
|
||||
session?: { execCwd?: string; spawnedCwd?: string };
|
||||
};
|
||||
const gatewayWorkspaceDir = described.session?.execCwd ?? described.session?.spawnedCwd;
|
||||
expect(gatewayWorkspaceDir).toBeTruthy();
|
||||
await expect(fs.readFile(path.join(gatewayWorkspaceDir!, EXEC_FILE), "utf8")).resolves.toBe(
|
||||
EXEC_MARKER,
|
||||
);
|
||||
const history = await operator.request<{ messages?: unknown[] }>("chat.history", {
|
||||
sessionKey: SESSION_KEY,
|
||||
limit: 20,
|
||||
});
|
||||
expect(
|
||||
history.messages?.some(
|
||||
(message) =>
|
||||
(message as { role?: unknown }).role === "assistant" &&
|
||||
wireMessageText(message).includes(EXEC_MARKER),
|
||||
),
|
||||
).toBe(true);
|
||||
await expect(operator.request("exec.approval.list", {})).resolves.toEqual([]);
|
||||
expect(approvalEvents).toEqual([]);
|
||||
await workerNode.waitForInvokes();
|
||||
expect(workerNode.invokeErrors).toEqual([]);
|
||||
await workerNode.waitForWorkersIdle();
|
||||
await expect(
|
||||
dockerOutput(["ps", "--all", "--filter", `id=${container.id}`, "--format", "{{.ID}}"]),
|
||||
).resolves.toBe("");
|
||||
} finally {
|
||||
if (controlUiProof) {
|
||||
await controlUiProof.context.close();
|
||||
await controlUiProof.browser.close();
|
||||
console.info(
|
||||
`[node-worker-container-wire] Control UI proof artifacts: ${controlUiProof.artifactDir}`,
|
||||
);
|
||||
}
|
||||
const cleanup = await Promise.allSettled([
|
||||
workerNode?.stop() ?? Promise.resolve(),
|
||||
operator?.stopAndWait({ timeoutMs: 2_000 }) ?? Promise.resolve(),
|
||||
gateway?.stop() ?? Promise.resolve(),
|
||||
provider.stop(),
|
||||
closeWireServer(published.server),
|
||||
]);
|
||||
const failures = cleanup.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
if (failures.length === 1) {
|
||||
throw failures[0];
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures, "node worker container wire cleanup failed");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { promisify } from "node:util";
|
||||
import { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import { startQaGatewayChild } from "../../../../extensions/qa-lab/api.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_CAPS,
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
} from "../../../../packages/gateway-protocol/src/client-info.js";
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from "../../../../src/infra/node-runner-inventory.js";
|
||||
import { handleInvoke, type NodeInvokeRequestPayload } from "../../../../src/node-host/invoke.js";
|
||||
import { NodeWorkerBundleInstaller } from "../../../../src/node-host/node-worker-bundle-installer.js";
|
||||
import type { NodeWorkerContainerEngine } from "../../../../src/node-host/node-worker-container-engine.js";
|
||||
import { parseNodeWorkerLaunchInput } from "../../../../src/node-host/node-worker-supervisor-contract.js";
|
||||
import { createNodeWorkerSupervisor } from "../../../../src/node-host/node-worker-supervisor.js";
|
||||
import { NodeWorkerWorkspaceRuntime } from "../../../../src/node-host/node-worker-workspace.js";
|
||||
@@ -141,6 +143,7 @@ export async function connectWireClient(params: {
|
||||
gateway: WireGateway;
|
||||
role: "operator" | "node";
|
||||
identity: DeviceIdentity | null;
|
||||
includeApprovals?: boolean;
|
||||
onEvent?: (event: WireGatewayEvent) => void;
|
||||
timeoutMs?: number;
|
||||
}): Promise<GatewayClient> {
|
||||
@@ -176,8 +179,20 @@ export async function connectWireClient(params: {
|
||||
platform: node ? "macos" : process.platform,
|
||||
deviceFamily: node ? "Mac" : undefined,
|
||||
mode: node ? GATEWAY_CLIENT_MODES.NODE : GATEWAY_CLIENT_MODES.BACKEND,
|
||||
scopes: node ? [] : ["operator.admin", "operator.pairing", "operator.read", "operator.write"],
|
||||
caps: node ? ["system"] : undefined,
|
||||
scopes: node
|
||||
? []
|
||||
: [
|
||||
"operator.admin",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
...(params.includeApprovals ? ["operator.approvals"] : []),
|
||||
],
|
||||
caps: node
|
||||
? ["system"]
|
||||
: params.includeApprovals
|
||||
? [GATEWAY_CLIENT_CAPS.APPROVALS, GATEWAY_CLIENT_CAPS.EXEC_APPROVALS]
|
||||
: undefined,
|
||||
commands: node ? [] : undefined,
|
||||
deviceIdentity: params.identity,
|
||||
requestTimeoutMs: PROOF_TIMEOUT_MS,
|
||||
@@ -255,6 +270,10 @@ type WireWorkerHostOptions = {
|
||||
label?: string;
|
||||
capacity?: number;
|
||||
capacityWaitMs?: number;
|
||||
containerEngine?: NodeWorkerContainerEngine;
|
||||
containerImage?: string;
|
||||
workerGatewayUrl?: string;
|
||||
workerEnv?: NodeJS.ProcessEnv;
|
||||
bundlePrewarm?: boolean;
|
||||
bundleRetention?: boolean;
|
||||
bundleStatus?: boolean;
|
||||
@@ -291,6 +310,7 @@ export async function createPairedNodeWorkerHost(
|
||||
HOME: path.join(options.root, `${label}-home`),
|
||||
NODE_DISABLE_COMPILE_CACHE: undefined,
|
||||
OPENCLAW_STATE_DIR: nodeStateDir,
|
||||
...options.workerEnv,
|
||||
};
|
||||
await fs.mkdir(nodeEnv.HOME, { recursive: true });
|
||||
const workspace = new NodeWorkerWorkspaceRuntime({ root: nodeHostRoot, env: nodeEnv });
|
||||
@@ -323,6 +343,8 @@ export async function createPairedNodeWorkerHost(
|
||||
workspace,
|
||||
capacity: options.capacity,
|
||||
capacityWaitMs: options.capacityWaitMs,
|
||||
...(options.containerEngine ? { containerEngine: options.containerEngine } : {}),
|
||||
...(options.containerImage ? { containerImage: options.containerImage } : {}),
|
||||
onCapacityChanged: (nextCapacity) => {
|
||||
capacity = nextCapacity;
|
||||
},
|
||||
@@ -344,7 +366,10 @@ export async function createPairedNodeWorkerHost(
|
||||
workerBundleInstaller: bundleInstaller,
|
||||
workerSupervisor: supervisor,
|
||||
workerWorkspace: workspace,
|
||||
gatewayUrl: options.gateway.wsUrl,
|
||||
gatewayUrl:
|
||||
frame.command === NODE_WORKER_SUPERVISOR_LAUNCH_COMMAND
|
||||
? (options.workerGatewayUrl ?? options.gateway.wsUrl)
|
||||
: options.gateway.wsUrl,
|
||||
})
|
||||
.then(async () => await options.afterInvoke?.(frame, host))
|
||||
.catch((error: unknown) => {
|
||||
@@ -480,6 +505,8 @@ export async function startPairedNodeWorkerGateway(params: {
|
||||
repoRoot?: string;
|
||||
useRepoCli?: boolean;
|
||||
workspaceDir?: string;
|
||||
controlUiEnabled?: boolean;
|
||||
fullAccess?: boolean;
|
||||
}): Promise<WireGateway> {
|
||||
return await startQaGatewayChild({
|
||||
repoRoot: params.repoRoot ?? process.cwd(),
|
||||
@@ -489,7 +516,7 @@ export async function startPairedNodeWorkerGateway(params: {
|
||||
primaryModel: MODEL_REF,
|
||||
alternateModel: MODEL_REF,
|
||||
transportBaseUrl: "http://127.0.0.1",
|
||||
controlUiEnabled: false,
|
||||
controlUiEnabled: params.controlUiEnabled ?? false,
|
||||
mutateConfig: (config) => ({
|
||||
...config,
|
||||
agents: {
|
||||
@@ -509,6 +536,14 @@ export async function startPairedNodeWorkerGateway(params: {
|
||||
audit: { ...config.logging?.audit, enabled: true, executionIdentity: true },
|
||||
}
|
||||
: config.logging,
|
||||
...(params.fullAccess
|
||||
? {
|
||||
tools: {
|
||||
...config.tools,
|
||||
exec: { ...config.tools?.exec, mode: "full" as const },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
nodeHost: {
|
||||
...config.nodeHost,
|
||||
workerRuns: { enabled: true },
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { gatewayOriginScope } from "@openclaw/gateway-client/browser";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
WORKSPACE,
|
||||
captureDeviceRuntimeUiProof,
|
||||
controlUiSessionPath,
|
||||
createNewSessionPageE2eSuite,
|
||||
createdSessionListResult,
|
||||
@@ -99,6 +101,97 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("restores the selected agent's own destination instead of inheriting another agent's device", async () => {
|
||||
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
const appUrl = new URL(suite.server.baseUrl);
|
||||
const gatewayUrl = `${appUrl.protocol === "https:" ? "wss:" : "ws:"}//${appUrl.host}`;
|
||||
const storageKey = `openclaw.new-session.preferences.v1:${gatewayOriginScope(gatewayUrl)}`;
|
||||
const sessionKey = "agent:research:local-after-agent-switch";
|
||||
await page.addInitScript(
|
||||
({ key, workspace }) => {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
agents: {
|
||||
research: {
|
||||
workspace,
|
||||
folder: workspace,
|
||||
where: { kind: "local" },
|
||||
worktree: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ key: storageKey, workspace: WORKSPACE },
|
||||
);
|
||||
const gateway = await installMockGateway(page, {
|
||||
operatorScopes: ["operator.read", "operator.write"],
|
||||
workspace: WORKSPACE,
|
||||
workspaceGit: true,
|
||||
methodResponses: {
|
||||
"agents.list": {
|
||||
agents: [
|
||||
{ id: "main", workspace: WORKSPACE, workspaceGit: true },
|
||||
{ id: "research", workspace: WORKSPACE, workspaceGit: true },
|
||||
],
|
||||
defaultId: "main",
|
||||
mainKey: "main",
|
||||
scope: "agent",
|
||||
},
|
||||
"environments.list": {
|
||||
environments: [
|
||||
{
|
||||
id: "node:paired-runner",
|
||||
type: "node",
|
||||
label: "Paired runner",
|
||||
status: "available",
|
||||
sessionHost: true,
|
||||
workerSlots: { total: 2, available: 1 },
|
||||
},
|
||||
],
|
||||
profiles: [],
|
||||
},
|
||||
"sessions.create": { key: sessionKey },
|
||||
"sessions.list": createdSessionListResult(sessionKey),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}new`);
|
||||
await gateway.waitForRequest("environments.list");
|
||||
const where = page.locator("#new-session-where-trigger");
|
||||
await where.click();
|
||||
await page.locator('[data-value="device:paired-runner"]').click();
|
||||
await expect.poll(() => where.getAttribute("data-device-id")).toBe("paired-runner");
|
||||
await captureDeviceRuntimeUiProof(page, "01-main-agent-paired-node-selected.png");
|
||||
|
||||
const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select");
|
||||
await agentPicker.locator(".agent-select__trigger").click();
|
||||
await agentPicker.getByRole("menuitemradio", { name: "research", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => agentPicker.locator(".agent-select__label").textContent())
|
||||
.toBe("research");
|
||||
await expect.poll(() => where.getAttribute("data-device-id")).toBeNull();
|
||||
await expect
|
||||
.poll(() => where.locator(".new-session-page__trigger-label").textContent())
|
||||
.toBe("Local");
|
||||
await captureDeviceRuntimeUiProof(page, "02-research-agent-local-destination-restored.png");
|
||||
|
||||
const message = "run this agent locally";
|
||||
await page.locator(".new-session-page__message").fill(message);
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
const create = await gateway.waitForRequest("sessions.create");
|
||||
expect(create.params).toMatchObject({ agentId: "research", message });
|
||||
expect(create.params).not.toHaveProperty("worktree");
|
||||
expect(await gateway.getRequests("sessions.dispatch")).toHaveLength(0);
|
||||
expect(await gateway.getRequests("sessions.send")).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("reloads a pending device create with the same placement target", async () => {
|
||||
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
|
||||
@@ -439,6 +439,7 @@ export class DraftPlaceState {
|
||||
this.preferredProjectRestore = "";
|
||||
this.whereSelectedByUser = false;
|
||||
this.projectSelectedByUser = false;
|
||||
this.deviceIdValue = "";
|
||||
this.cloudProfileIdValue = "";
|
||||
this.autoDeviceValue = false;
|
||||
this.repositoryState.reset();
|
||||
|
||||
Reference in New Issue
Block a user