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:
Peter Steinberger
2026-08-25 14:01:39 -07:00
committed by GitHub
parent 59e55d2dfe
commit a6ebad9964
7 changed files with 581 additions and 22 deletions
+17 -17
View File
@@ -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.
+1
View File
@@ -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",
+19 -1
View File
@@ -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();