From a6ebad9964c4de4dac1f0e0445a23eb690f5e009 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 14:01:39 -0700 Subject: [PATCH] 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 --- docs/cli/worker.md | 34 +- src/worker/embedded-agent.runtime.ts | 1 + src/worker/worker.runtime.test.ts | 20 +- .../node-worker-container-wire.e2e.test.ts | 411 ++++++++++++++++++ .../paired-node-worker-wire-fixture.ts | 43 +- ...w-session-page.device-dispatch.e2e.test.ts | 93 ++++ ui/src/pages/new-session/draft-place-state.ts | 1 + 7 files changed, 581 insertions(+), 22 deletions(-) create mode 100644 test/e2e/qa-lab/runtime/node-worker-container-wire.e2e.test.ts diff --git a/docs/cli/worker.md b/docs/cli/worker.md index e065d668a0d7..2ed49492fd49 100644 --- a/docs/cli/worker.md +++ b/docs/cli/worker.md @@ -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. diff --git a/src/worker/embedded-agent.runtime.ts b/src/worker/embedded-agent.runtime.ts index aa289c0081e0..e2e13df78a12 100644 --- a/src/worker/embedded-agent.runtime.ts +++ b/src/worker/embedded-agent.runtime.ts @@ -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", diff --git a/src/worker/worker.runtime.test.ts b/src/worker/worker.runtime.test.ts index 4a611ef993b2..af3c81e22d30 100644 --- a/src/worker/worker.runtime.test.ts +++ b/src/worker/worker.runtime.test.ts @@ -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 { }, { 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); } }); diff --git a/test/e2e/qa-lab/runtime/node-worker-container-wire.e2e.test.ts b/test/e2e/qa-lab/runtime/node-worker-container-wire.e2e.test.ts new file mode 100644 index 000000000000..22cffb0e465e --- /dev/null +++ b/test/e2e/qa-lab/runtime/node-worker-container-wire.e2e.test.ts @@ -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; + 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 { + const { stdout } = await execFileAsync("docker", args, { + encoding: "utf8", + timeout: 15_000, + }); + return stdout.trim(); +} + +async function observeWorkerContainer(launchId: string): Promise { + 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; + 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 { + 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 { + 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 | 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"); + } + } + }, + ); +}); diff --git a/test/e2e/qa-lab/runtime/paired-node-worker-wire-fixture.ts b/test/e2e/qa-lab/runtime/paired-node-worker-wire-fixture.ts index 867a82555762..c419c74828a2 100644 --- a/test/e2e/qa-lab/runtime/paired-node-worker-wire-fixture.ts +++ b/test/e2e/qa-lab/runtime/paired-node-worker-wire-fixture.ts @@ -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 { @@ -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 { 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 }, diff --git a/ui/src/e2e/new-session-page.device-dispatch.e2e.test.ts b/ui/src/e2e/new-session-page.device-dispatch.e2e.test.ts index 0c1e4db34414..b52799449790 100644 --- a/ui/src/e2e/new-session-page.device-dispatch.e2e.test.ts +++ b/ui/src/e2e/new-session-page.device-dispatch.e2e.test.ts @@ -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(); diff --git a/ui/src/pages/new-session/draft-place-state.ts b/ui/src/pages/new-session/draft-place-state.ts index 9f5f23935dbf..ed9fad420f15 100644 --- a/ui/src/pages/new-session/draft-place-state.ts +++ b/ui/src/pages/new-session/draft-place-state.ts @@ -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();