From 87702484d123ccdda5b06e8a2faba9501a407a6a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 14:58:24 -0700 Subject: [PATCH] fix: worker turns fail on older session-host nodes after the Gateway adds a worker tool (#160147) * fix(gateway): keep worker turns working on session hosts that predate new worker tools A Gateway built from main added the `presence` worker session tool, and every worker-turn launch to a published openclaw@2026.9.6 session-host node failed with `INVALID_REQUEST: invalid worker launch descriptor` followed by `node worker cancellation timed out`. The installed node supervisor validates `toolAuthority.allowedToolNames` against a closed vocabulary, and bundle refresh does not replace the supervisor. The Gateway now negotiates the launch vocabulary per node, following the existing capability pattern: it advertises `node-worker-launch-tool-names-v1`, updated nodes declare `workerHost.launchToolNames` only to Gateways that advertise it, and the Gateway treats an absent declaration as the frozen 2026.9.6 vocabulary. Tool authority filters the projected tool set by the destination vocabulary, so turn authorization and the launch descriptor stay identical. Unknown declared names are ignored, so future worker tools need no further capability. Update behavior: Gateway-first updates keep older nodes hosting turns without newer tools (one info log names them); node-first updates keep the declaration unchanged for older Gateways; updated pairs get presence. * fix(scripts): add worker tool authority to the PR wrapper inventory src/infra/node-runner-inventory.ts is in the trusted-anchor PR wrapper's runtime import closure and now imports src/worker/tool-authority.ts for launch tool-name negotiation; the extracted wrapper could not resolve it. * test(gateway): await the shared async node tunnel manager fixture Main moved createManager into node-worker-tunnel.test-support.ts as an async helper (#160415); the launch-vocabulary lifecycle test still called it synchronously. --- docs/nodes/session-hosting.md | 5 + .../src/server-capabilities.ts | 1 + scripts/pr-lib/wrapper-components.txt | 1 + .../run.overflow-compaction.loop.test.ts | 3 + src/gateway/node-registry-private.ts | 8 +- src/gateway/node-runner-inventory-runtime.ts | 3 + .../server/ws-connection/connect-hello.ts | 1 + .../cloud-transcript-write-admission.test.ts | 2 + .../environment-access-authority.test.ts | 3 +- .../environment-access.test.ts | 3 +- .../node-worker-tunnel.lifecycle.test.ts | 23 +++++ .../worker-environments/node-worker-tunnel.ts | 5 + .../placement-dispatch-test-harness.ts | 10 +- .../provider-replay.test-support.ts | 3 +- .../worker-environments/tunnel-contract.ts | 6 +- .../worker-desktop-launch-plan.ts | 3 + .../worker-tool-authority.test.ts | 12 ++- .../worker-tool-authority.ts | 17 +++- .../worker-turn-attachments.boundary.test.ts | 2 + .../worker-turn-detached-context.test.ts | 2 + .../worker-turn-execution.test.ts | 63 +++++++++++++ .../worker-turn-execution.ts | 8 +- .../worker-turn-launcher-computer.test.ts | 14 ++- .../worker-turn-launcher.test-support.ts | 5 + .../worker-turn-run-owner.test.ts | 2 + .../worker-turn-shutdown.test.ts | 1 + .../worker-turn-trajectory.test.ts | 2 + src/infra/node-runner-inventory.ts | 36 ++++++++ src/node-host/connection.test.ts | 91 ++++++++++++++++++- src/node-host/connection.ts | 4 + ...ker-initial-setup.real-gateway.e2e.test.ts | 2 + 31 files changed, 319 insertions(+), 22 deletions(-) diff --git a/docs/nodes/session-hosting.md b/docs/nodes/session-hosting.md index 02bf4372e57b..88ca8387b9bf 100644 --- a/docs/nodes/session-hosting.md +++ b/docs/nodes/session-hosting.md @@ -104,6 +104,11 @@ mixed Gateway/node versions: update either side first, and older node hosts continue to use status polling. A newer node advertises `workerHost.statusWait: 1` only to a Gateway that announces the capability. Reconnects renegotiate support. +Worker tools newer than a node's installed OpenClaw, such as `presence`, are +offered only when the node's supervisor declares support. Older nodes keep +hosting OpenClaw worker turns without those tools. Update OpenClaw on the node +and restart it to enable them. + This setting enables supervised session turns on the paired device, including Gateway-owned workspace transfer and result reconciliation. By default, each node has one worker slot per available CPU core. Configure the slot count with diff --git a/packages/gateway-protocol/src/server-capabilities.ts b/packages/gateway-protocol/src/server-capabilities.ts index b608596303b7..cdd06817eb9e 100644 --- a/packages/gateway-protocol/src/server-capabilities.ts +++ b/packages/gateway-protocol/src/server-capabilities.ts @@ -9,6 +9,7 @@ export const GATEWAY_SERVER_CAPS = { NODE_WORKER_BUNDLE_STATUS: "node-worker-bundle-status-v1", NODE_WORKER_CAPTURED_EXEC_POLICY: "node-worker-captured-exec-policy", NODE_WORKER_ENVIRONMENT_SESSION: "node-worker-environment-session-v1", + NODE_WORKER_LAUNCH_TOOL_NAMES: "node-worker-launch-tool-names-v1", NODE_WORKER_PORTAL_STREAM: "node-worker-portal-stream-v1", NODE_WORKER_STATUS_WAIT: "node-worker-status-wait-v1", PUBLISHED_MODEL_CATALOG: "published-model-catalog", diff --git a/scripts/pr-lib/wrapper-components.txt b/scripts/pr-lib/wrapper-components.txt index e3d9be28c8a8..e86d2f2f1851 100644 --- a/scripts/pr-lib/wrapper-components.txt +++ b/scripts/pr-lib/wrapper-components.txt @@ -1397,6 +1397,7 @@ src/utils/utf8-truncate.ts src/utils/zod-parse.ts src/version.ts src/worker/protocol-record.ts +src/worker/tool-authority.ts test/helpers/temp-dir.ts test/test-home-context.mts test/test-home-policy.mts diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts index d0d4cfa9ba0f..143f88520ef0 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts @@ -5,6 +5,7 @@ import type { GatewayRequestContext } from "../../gateway/server-methods/types.j import { resolveWorkerToolAuthority } from "../../gateway/worker-environments/worker-tool-authority.js"; import { getAgentEventLifecycleGeneration } from "../../infra/agent-events.js"; import { bindGatewayContextResolver } from "../../plugins/runtime/gateway-request-scope.js"; +import { WORKER_TOOL_NAMES } from "../../worker/tool-authority.js"; import { mergeAcceptedSessionSpawnsForRun } from "../accepted-session-spawn.js"; import { prepareSystemAgentRunAdmission, @@ -277,6 +278,7 @@ describe("embedded run retry dispatch", () => { }); const authority = resolveWorkerToolAuthority({ + launchToolNames: WORKER_TOOL_NAMES, modelRef: { provider: "openai", model: "gpt-5.6-luna" }, turn: result.preparedAttempt as unknown as SessionPlacementTurnParams, }); @@ -294,6 +296,7 @@ describe("embedded run retry dispatch", () => { const result = await dispatchExecSession({ sandbox: "required" }); const authority = resolveWorkerToolAuthority({ + launchToolNames: WORKER_TOOL_NAMES, modelRef: { provider: "openai", model: "gpt-5.6-luna" }, turn: result.preparedAttempt as unknown as SessionPlacementTurnParams, }); diff --git a/src/gateway/node-registry-private.ts b/src/gateway/node-registry-private.ts index ca0ef5a3c00c..607881b55db0 100644 --- a/src/gateway/node-registry-private.ts +++ b/src/gateway/node-registry-private.ts @@ -177,7 +177,13 @@ function updateWorkerRunnerInventory( ...(workerHost ? { workerHost: workerHost.enabled - ? { ...workerHost, capacity: { ...workerHost.capacity } } + ? { + ...workerHost, + capacity: { ...workerHost.capacity }, + ...(workerHost.launchToolNames !== undefined + ? { launchToolNames: [...workerHost.launchToolNames] } + : {}), + } : { enabled: false }, } : {}), diff --git a/src/gateway/node-runner-inventory-runtime.ts b/src/gateway/node-runner-inventory-runtime.ts index dfa0d335db0d..e91006067f0a 100644 --- a/src/gateway/node-runner-inventory-runtime.ts +++ b/src/gateway/node-runner-inventory-runtime.ts @@ -249,6 +249,9 @@ export function resolveNodeWorkerSupervisorProof( workerHost: { ...declaration.workerHost, capacity: { ...declaration.workerHost.capacity }, + ...(declaration.workerHost.launchToolNames !== undefined + ? { launchToolNames: [...declaration.workerHost.launchToolNames] } + : {}), }, commands: [...node.commands], }; diff --git a/src/gateway/server/ws-connection/connect-hello.ts b/src/gateway/server/ws-connection/connect-hello.ts index 868b4458f785..bfe1b090377c 100644 --- a/src/gateway/server/ws-connection/connect-hello.ts +++ b/src/gateway/server/ws-connection/connect-hello.ts @@ -181,6 +181,7 @@ export async function sendGatewayHello( GATEWAY_SERVER_CAPS.NODE_WORKER_BUNDLE_STATUS, GATEWAY_SERVER_CAPS.NODE_WORKER_CAPTURED_EXEC_POLICY, GATEWAY_SERVER_CAPS.NODE_WORKER_ENVIRONMENT_SESSION, + GATEWAY_SERVER_CAPS.NODE_WORKER_LAUNCH_TOOL_NAMES, GATEWAY_SERVER_CAPS.NODE_WORKER_PORTAL_STREAM, GATEWAY_SERVER_CAPS.NODE_WORKER_STATUS_WAIT, GATEWAY_SERVER_CAPS.PROFILE_BINDING, diff --git a/src/gateway/worker-environments/cloud-transcript-write-admission.test.ts b/src/gateway/worker-environments/cloud-transcript-write-admission.test.ts index d3d7318ae34b..47d006f4da8f 100644 --- a/src/gateway/worker-environments/cloud-transcript-write-admission.test.ts +++ b/src/gateway/worker-environments/cloud-transcript-write-admission.test.ts @@ -15,6 +15,7 @@ import { createWorkerSessionTurnPlacementProvider, credential, measureLaunchTurn, + readLaunchToolNames, placements, root, seedActivePlacement, @@ -80,6 +81,7 @@ describe("cloud transcript write admission", () => { ownerEpoch: OWNER_EPOCH, runWorkspaceCommand: vi.fn(), measureLaunchTurn, + readLaunchToolNames, launchTurn: launch, quiesceWorkspace: vi.fn(), reconcileWorkspace: vi.fn(), diff --git a/src/gateway/worker-environments/environment-access-authority.test.ts b/src/gateway/worker-environments/environment-access-authority.test.ts index 730ea80c916b..280be7b34d7e 100644 --- a/src/gateway/worker-environments/environment-access-authority.test.ts +++ b/src/gateway/worker-environments/environment-access-authority.test.ts @@ -3,7 +3,7 @@ import { createDeferred } from "../../../test/helpers/promise.js"; import type { WorkerEnvironmentNodeTunnel } from "./environment-access.js"; import { createStoppedTunnelManager } from "./environment-access.test-support.js"; import * as support from "./service.test-support.js"; -import { measureLaunchTurn } from "./worker-turn-launcher.test-support.js"; +import { measureLaunchTurn, readLaunchToolNames } from "./worker-turn-launcher.test-support.js"; describe("worker environment startup authority", () => { support.setupWorkerEnvironmentServiceSuite(); @@ -53,6 +53,7 @@ describe("worker environment startup authority", () => { environmentId: "pending", ownerEpoch: 0, measureLaunchTurn, + readLaunchToolNames, launchTurn: vi.fn(), runWorkspaceCommand: vi.fn(), quiesceWorkspace: vi.fn(), diff --git a/src/gateway/worker-environments/environment-access.test.ts b/src/gateway/worker-environments/environment-access.test.ts index 7b60c2fa01b6..92b198b7c83d 100644 --- a/src/gateway/worker-environments/environment-access.test.ts +++ b/src/gateway/worker-environments/environment-access.test.ts @@ -16,7 +16,7 @@ import { createWorkerNodePortalCarrier } from "./portal-node-carrier.js"; import * as support from "./service.test-support.js"; import { createWorkerEnvironmentStore } from "./store.js"; import { createWorkerTunnelManager, type WorkerTunnelManager } from "./tunnel.js"; -import { measureLaunchTurn } from "./worker-turn-launcher.test-support.js"; +import { measureLaunchTurn, readLaunchToolNames } from "./worker-turn-launcher.test-support.js"; type WorkerEnvironmentServiceError = support.WorkerEnvironmentServiceError; @@ -415,6 +415,7 @@ describe("worker environment service", () => { environmentId: request.environmentId, ownerEpoch: request.ownerEpoch, measureLaunchTurn, + readLaunchToolNames, launchTurn: vi.fn(), runWorkspaceCommand: vi.fn(), syncWorkspace: vi.fn(), diff --git a/src/gateway/worker-environments/node-worker-tunnel.lifecycle.test.ts b/src/gateway/worker-environments/node-worker-tunnel.lifecycle.test.ts index 12af9fe9ff5c..b4c94285892a 100644 --- a/src/gateway/worker-environments/node-worker-tunnel.lifecycle.test.ts +++ b/src/gateway/worker-environments/node-worker-tunnel.lifecycle.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { WORKER_RPC_SET_VERSION } from "../../../packages/gateway-protocol/src/schema/worker-admission.js"; import { createDeferred } from "../../../test/helpers/promise.js"; import { NODE_WORKER_ENVIRONMENT_STOP_COMMAND } from "../../infra/node-commands.js"; +import { resolveNodeWorkerLaunchToolNames } from "../../infra/node-runner-inventory.js"; import { parseWorkerLaunchPlan } from "../../worker/launch-descriptor.js"; import type { NodeWorkerSupervisorReceipt } from "../../worker/node-supervisor-protocol.js"; import { @@ -70,6 +71,28 @@ function turnClaim() { } describe("node worker tunnel lifetime", () => { + it("reads the current destination's launch vocabulary with a legacy fallback while offline", async () => { + const nodeTransport = transport(); + const nodes = await nodeTransport.listCurrentNodes(); + const node = nodes[0]!; + nodeTransport.listCurrentNodes = async () => nodes; + let currentTransport: NodeWorkerSupervisorTransport | undefined = nodeTransport; + const manager = await createManager(environment(), { getTransport: () => currentTransport }); + const handle = await manager.start(startRequest()); + const legacy = resolveNodeWorkerLaunchToolNames(node.workerHost); + await expect(handle.readLaunchToolNames()).resolves.toEqual(legacy); + + node.workerHost.launchToolNames = []; + await expect(handle.readLaunchToolNames()).resolves.toEqual([]); + node.workerHost.launchToolNames = ["read", "presence"]; + await expect(handle.readLaunchToolNames()).resolves.toEqual(["read", "presence"]); + + nodeTransport.listCurrentNodes = async () => []; + await expect(handle.readLaunchToolNames()).resolves.toEqual(legacy); + currentTransport = undefined; + await expect(handle.readLaunchToolNames()).resolves.toEqual(legacy); + }); + it("revalidates the exact claim when a same-run replacement launches", async () => { const record = environment(); let currentClaim = turnClaim(); diff --git a/src/gateway/worker-environments/node-worker-tunnel.ts b/src/gateway/worker-environments/node-worker-tunnel.ts index 7a9cd84f47c7..bcbc52383df8 100644 --- a/src/gateway/worker-environments/node-worker-tunnel.ts +++ b/src/gateway/worker-environments/node-worker-tunnel.ts @@ -9,6 +9,7 @@ import { formatNodeRunnerUpdateRequired, NODE_RUNNER_UPDATE_REQUIRED_ISSUE, NODE_WORKER_ENVIRONMENT_SESSION_VERSION, + resolveNodeWorkerLaunchToolNames, } from "../../infra/node-runner-inventory.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import type { SpawnResult } from "../../process/exec.js"; @@ -351,6 +352,10 @@ export function createNodeWorkerTunnelManager(options: NodeWorkerTunnelManagerOp ownerEpoch: entry.ownerEpoch, measureLaunchTurn: (plan, claim) => measureNodeWorkerLaunchBytes(entry.deviceId, buildLaunchInput(plan, claim)), + readLaunchToolNames: async () => { + const node = await options.getTransport()?.getCurrentNode(entry.deviceId); + return resolveNodeWorkerLaunchToolNames(node?.workerHost); + }, launchTurn: async (request) => { if (entry.executionMode !== "worker-turn") { throw new Error("remote-exec environments do not launch embedded worker turns"); diff --git a/src/gateway/worker-environments/placement-dispatch-test-harness.ts b/src/gateway/worker-environments/placement-dispatch-test-harness.ts index d4e6b3545ee5..0c796915212d 100644 --- a/src/gateway/worker-environments/placement-dispatch-test-harness.ts +++ b/src/gateway/worker-environments/placement-dispatch-test-harness.ts @@ -32,6 +32,7 @@ import { type WorkerTurnTunnelHandle, type WorkerWorkspaceReconcileRequest, } from "./tunnel-contract.js"; +import { readLaunchToolNames } from "./worker-turn-launcher.test-support.js"; import { projectWorkspaceResultConflict, type WorkspaceResultConflictLookup, @@ -212,6 +213,7 @@ export function createHarness( environmentId: ready.environmentId, ownerEpoch, measureLaunchTurn: vi.fn(), + readLaunchToolNames, launchTurn: vi.fn(), quiesceWorkspace: vi.fn(async () => { log.push("workspace:quiesce"); @@ -577,8 +579,7 @@ export function createHarness( seedProvisioning: (executionMode?: "worker-turn" | "remote-exec") => seedProvisioningPlacement(placementStore, environmentId, executionMode), seedStarting: () => seedStartingPlacement(placementStore, environmentId), - seedActive: (ownerEpoch: number, executionMode?: "worker-turn" | "remote-exec") => - seedActive(ownerEpoch, executionMode), + seedActive, seedDraining: async (ownerEpoch: number) => { const active = await seedActive(ownerEpoch); if (active.state !== "active") { @@ -620,9 +621,8 @@ export function createHarness( markEnvironmentNodeDeviceId: (nodeDeviceId: string) => { setEnvironment({ ...attached, providerId: "device", nodeDeviceId, sshEndpoint: null }); }, - markEnvironmentAttachments: (attachedSessionIds: string[]) => { - setEnvironment({ ...attached, attachedSessionIds }); - }, + markEnvironmentAttachments: (attachedSessionIds: string[]) => + setEnvironment({ ...attached, attachedSessionIds }), markEnvironmentProtocolFeatures: (protocolFeatures: string[]) => { if (!currentEnvironment?.bootstrapReceipt) { throw new Error("worker environment fixture has no bootstrap receipt"); diff --git a/src/gateway/worker-environments/provider-replay.test-support.ts b/src/gateway/worker-environments/provider-replay.test-support.ts index 76d630b96eb6..02b2b80f4078 100644 --- a/src/gateway/worker-environments/provider-replay.test-support.ts +++ b/src/gateway/worker-environments/provider-replay.test-support.ts @@ -8,7 +8,7 @@ import { bindDeviceWorkerAvailability } from "./device-provider.js"; import type { WorkerEnvironmentNodeTunnel } from "./environment-access.js"; import { createWorkerPlacementDispatchService } from "./placement-dispatch.js"; import type { WorkerTurnTunnelHandle } from "./tunnel-contract.js"; -import { measureLaunchTurn } from "./worker-turn-launcher.test-support.js"; +import { measureLaunchTurn, readLaunchToolNames } from "./worker-turn-launcher.test-support.js"; import { createWorkerWorkspaceOperationCoordinator } from "./workspace-operation-coordinator.js"; import { createWorkerWorkspaceRecoveryFixture } from "./workspace-recovery.test-support.js"; @@ -24,6 +24,7 @@ export function createProviderReplayNodeTunnel() { environmentId, ownerEpoch, measureLaunchTurn, + readLaunchToolNames, launchTurn: vi.fn(), runWorkspaceCommand: vi.fn(), quiesceWorkspace: vi.fn(), diff --git a/src/gateway/worker-environments/tunnel-contract.ts b/src/gateway/worker-environments/tunnel-contract.ts index b22ba2c38e81..edbf99b007f2 100644 --- a/src/gateway/worker-environments/tunnel-contract.ts +++ b/src/gateway/worker-environments/tunnel-contract.ts @@ -6,6 +6,7 @@ import type { NodeWorkerWorkspaceProcessInput, } from "../../worker/node-workspace-protocol.js"; import type { NodeWorkerWorkspaceTransferInput } from "../../worker/node-workspace-transfer-protocol.js"; +import type { WorkerToolName } from "../../worker/tool-authority.js"; import type { WorkerSessionTurnClaim } from "./placement-record.js"; import type { WorkerWorkspaceApplyResult, @@ -237,6 +238,7 @@ export type WorkerWorkspaceTunnelHandle = { ownerEpoch: number; launchTurn?: never; measureLaunchTurn?: never; + readLaunchToolNames?: never; runWorkspaceCommand(command: WorkerWorkspaceCommand): Promise; stageAttachments?(request: { localPath: string; @@ -253,9 +255,11 @@ export type WorkerWorkspaceTunnelHandle = { export type WorkerTurnTunnelHandle = Omit< WorkerWorkspaceTunnelHandle, - "launchTurn" | "measureLaunchTurn" + "launchTurn" | "measureLaunchTurn" | "readLaunchToolNames" > & { measureLaunchTurn(plan: WorkerLaunchPlan, claim: WorkerSessionTurnClaim): number; + /** Worker tool names the destination's installed supervisor admits in launch descriptors. */ + readLaunchToolNames(): Promise; launchTurn(request: WorkerTurnLaunchRequest): Promise; }; diff --git a/src/gateway/worker-environments/worker-desktop-launch-plan.ts b/src/gateway/worker-environments/worker-desktop-launch-plan.ts index 898e07fa891f..ca58fcdb3336 100644 --- a/src/gateway/worker-environments/worker-desktop-launch-plan.ts +++ b/src/gateway/worker-environments/worker-desktop-launch-plan.ts @@ -9,6 +9,7 @@ import type { import type { WorkerToolAuthority, WorkerOptionalLocalToolName, + WorkerToolName, } from "../../worker/tool-authority.js"; import type { PreparedWorkerComputer } from "./computer-transport.js"; import { resolveWorkerToolAuthority } from "./worker-tool-authority.js"; @@ -20,6 +21,7 @@ export async function prepareWorkerDesktopLaunchPlan(params: { prepareComputer(): Promise | undefined; modelRef: { provider: string; model: string }; turn: SessionPlacementTurnParams; + launchToolNames: readonly WorkerToolName[]; portalAvailable?: boolean; }): Promise<{ browser?: WorkerBrowserLaunchDescriptor; @@ -51,6 +53,7 @@ export async function prepareWorkerDesktopLaunchPlan(params: { const toolAuthority = resolveWorkerToolAuthority({ modelRef: params.modelRef, turn: params.turn, + launchToolNames: params.launchToolNames, portalAvailable: params.portalAvailable, availableOptionalToolNames, }); diff --git a/src/gateway/worker-environments/worker-tool-authority.test.ts b/src/gateway/worker-environments/worker-tool-authority.test.ts index 53e057df41d4..dd9d48f48c0d 100644 --- a/src/gateway/worker-environments/worker-tool-authority.test.ts +++ b/src/gateway/worker-environments/worker-tool-authority.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveNodeExecutionTarget } from "../../agents/bash-tools.exec-host-node-phases.js"; import type { ExecuteNodeHostCommandParams } from "../../agents/bash-tools.exec-host-node.types.js"; import type { SessionPlacementTurnParams } from "../../agents/session-placement-admission.js"; +import { WORKER_TOOL_NAMES } from "../../worker/tool-authority.js"; import { resolveWorkerToolAuthority } from "./worker-tool-authority.js"; const gatewayMocks = vi.hoisted(() => ({ callGatewayTool: vi.fn() })); @@ -28,6 +29,7 @@ function turn(overrides: Partial = {}): SessionPlace function authority(overrides: Partial = {}, portalAvailable = false) { return resolveWorkerToolAuthority({ + launchToolNames: WORKER_TOOL_NAMES, modelRef: { provider: "openai", model: "gpt-test" }, turn: turn(overrides), portalAvailable, @@ -36,6 +38,7 @@ function authority(overrides: Partial = {}, portalAv function resolvedAuthority(overrides: Partial = {}) { return resolveWorkerToolAuthority({ + launchToolNames: WORKER_TOOL_NAMES, modelRef: { provider: "openai", model: "gpt-test" }, turn: turn(overrides), }); @@ -48,6 +51,7 @@ afterEach(() => { describe("resolveWorkerToolAuthority", () => { it("keeps browser available when a text-only model excludes computer", () => { const tools = resolveWorkerToolAuthority({ + launchToolNames: WORKER_TOOL_NAMES, modelRef: { provider: "openai", model: "gpt-test" }, turn: turn({ modelHasVision: false, toolsAllow: ["computer", "browser"] }), availableOptionalToolNames: ["computer", "browser"], @@ -80,7 +84,11 @@ describe("resolveWorkerToolAuthority", () => { sessionKey: "agent:main:worker-sandboxed", config: { agents: { defaults: { sandbox: { mode: "all" } } }, tools }, }); - const params = { modelRef: { provider: "openai", model: "gpt-test" }, turn: turnParams }; + const params = { + launchToolNames: WORKER_TOOL_NAMES, + modelRef: { provider: "openai", model: "gpt-test" }, + turn: turnParams, + }; expect(resolveWorkerToolAuthority(params).allowedToolNames).not.toContain("computer"); expect( resolveWorkerToolAuthority({ @@ -255,6 +263,7 @@ describe("resolveWorkerToolAuthority", () => { it("adds the optional browser surface only when the launcher makes it available", () => { expect( resolveWorkerToolAuthority({ + launchToolNames: WORKER_TOOL_NAMES, modelRef: { provider: "openai", model: "gpt-test" }, turn: turn(), availableOptionalToolNames: ["browser"], @@ -273,6 +282,7 @@ describe("resolveWorkerToolAuthority", () => { ]); expect( resolveWorkerToolAuthority({ + launchToolNames: WORKER_TOOL_NAMES, modelRef: { provider: "openai", model: "gpt-test" }, turn: turn({ toolsAllow: ["browser"] }), availableOptionalToolNames: ["browser"], diff --git a/src/gateway/worker-environments/worker-tool-authority.ts b/src/gateway/worker-environments/worker-tool-authority.ts index 4acb112b1d4c..5fc997a34b17 100644 --- a/src/gateway/worker-environments/worker-tool-authority.ts +++ b/src/gateway/worker-environments/worker-tool-authority.ts @@ -6,7 +6,7 @@ import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status import { resolveSandboxToolPolicyForAgent } from "../../agents/sandbox/tool-policy.js"; import { projectEffectiveExecPolicy } from "../../agents/session-permission-exec-mode.js"; import type { SessionPlacementTurnParams } from "../../agents/session-placement-admission.js"; -import { logWarn } from "../../logger.js"; +import { logInfo, logWarn } from "../../logger.js"; import { WORKER_REQUIRED_LOCAL_TOOL_NAMES, WORKER_SESSION_TOOL_NAMES, @@ -85,6 +85,7 @@ function resolveWorkerCapabilityProfile(params: { export function resolveWorkerToolAuthority(params: { modelRef: { provider: string; model: string }; turn: SessionPlacementTurnParams; + launchToolNames: readonly WorkerToolName[]; availableOptionalToolNames?: readonly WorkerOptionalLocalToolName[]; portalAvailable?: boolean; }): WorkerToolAuthority { @@ -146,10 +147,18 @@ export function resolveWorkerToolAuthority(params: { "Worker exec/process withheld: captured exec policy requires local host or interactive approval. Run this turn locally.", ); } + const launchToolNames = new Set(params.launchToolNames); + const withheld = projected.filter((name) => !launchToolNames.has(name)); + if (withheld.length > 0) { + logInfo( + `Worker tools withheld: the node's installed OpenClaw does not support ${withheld.join(", ")}. Update OpenClaw on the node and restart it to enable them.`, + ); + } return { - allowedToolNames: execUnavailable - ? projected.filter((name) => name !== "exec" && name !== "process") - : projected, + allowedToolNames: projected.filter( + (name) => + (!execUnavailable || (name !== "exec" && name !== "process")) && launchToolNames.has(name), + ), exec, }; } diff --git a/src/gateway/worker-environments/worker-turn-attachments.boundary.test.ts b/src/gateway/worker-environments/worker-turn-attachments.boundary.test.ts index f33e18ee5654..d6094ecea4e8 100644 --- a/src/gateway/worker-environments/worker-turn-attachments.boundary.test.ts +++ b/src/gateway/worker-environments/worker-turn-attachments.boundary.test.ts @@ -24,6 +24,7 @@ import { createWorkerSessionTurnPlacementProvider, credential, measureLaunchTurn, + readLaunchToolNames, openSessionManager, placements, root, @@ -146,6 +147,7 @@ describe("current attachments in an active remote placement", () => { }); }), measureLaunchTurn, + readLaunchToolNames, stageAttachments: async (request) => { const service = createNodeWorkspaceTransferService({ getOwner: () => ({ diff --git a/src/gateway/worker-environments/worker-turn-detached-context.test.ts b/src/gateway/worker-environments/worker-turn-detached-context.test.ts index 843737560778..24f75cb5574b 100644 --- a/src/gateway/worker-environments/worker-turn-detached-context.test.ts +++ b/src/gateway/worker-environments/worker-turn-detached-context.test.ts @@ -32,6 +32,7 @@ import { database, dispatchInitialWorkerPlacement, measureLaunchTurn, + readLaunchToolNames, placements, readWorkerTurnTranscriptStorageRows, root, @@ -123,6 +124,7 @@ async function launchProbe( reconcileWorkspace: unexpected, stop: async () => {}, measureLaunchTurn, + readLaunchToolNames, launchTurn: async ({ plan }) => { launch = { baseLeafId: plan.assignment.transcript.baseLeafId, diff --git a/src/gateway/worker-environments/worker-turn-execution.test.ts b/src/gateway/worker-environments/worker-turn-execution.test.ts index 7ebb4a2264dc..912d034bc50b 100644 --- a/src/gateway/worker-environments/worker-turn-execution.test.ts +++ b/src/gateway/worker-environments/worker-turn-execution.test.ts @@ -12,13 +12,16 @@ import { } from "../../agents/test-helpers/agent-message-fixtures.js"; import { patchSessionEntryCore } from "../../config/sessions/session-accessor.js"; import { setActiveNodeContexts } from "../../infra/active-node-context.js"; +import { resolveNodeWorkerLaunchToolNames } from "../../infra/node-runner-inventory.js"; import { requireNodeSqlite } from "../../infra/node-sqlite.js"; import { observeMainThreadSql } from "../../test-utils/main-thread-sql-spies.test-support.js"; import { completeWorkerLaunchDescriptor, + parseWorkerLaunchPlan, type WorkerLaunchPlan, } from "../../worker/launch-descriptor.js"; import { roundTripWorkerLaunchDescriptor } from "../../worker/launch-descriptor.test-support.js"; +import { WORKER_TOOL_NAMES } from "../../worker/tool-authority.js"; import { projectWorkerSessionTurnClaim } from "./placement-record.js"; import { createWorkerSessionPlacementGate } from "./placement-worker-gate.js"; import { WorkerRunnerCapacityError, type WorkerTunnelHandle } from "./tunnel-contract.js"; @@ -28,6 +31,7 @@ import { OWNER_EPOCH, credential, measureLaunchTurn, + readLaunchToolNames, SESSION_ID, SESSION_KEY, attachedEnvironment, @@ -48,6 +52,62 @@ describe("worker turn execution", () => { beforeEach(setupWorkerTurnLauncherTest); afterEach(cleanupWorkerTurnLauncherTest); + it.each([false, true])( + "launches only supervisor-admitted tools and authorizes the same set (declared: %s)", + async (declared) => { + await seedActivePlacement(); + const launchToolNames = resolveNodeWorkerLaunchToolNames({ + enabled: true, + capacity: { total: 1, available: 1 }, + environmentSession: 1, + capturedExecPolicy: true, + ...(declared ? { launchToolNames: WORKER_TOOL_NAMES } : {}), + }); + const authorize = vi.spyOn(placements, "authorizeWorkerTurnTools"); + const launchTurn = vi.fn>(async () => { + throw new WorkerRunnerCapacityError(); + }); + const tunnel: WorkerTunnelHandle = { + environmentId: ENVIRONMENT_ID, + ownerEpoch: OWNER_EPOCH, + launchTurn, + measureLaunchTurn, + readLaunchToolNames: async () => launchToolNames, + runWorkspaceCommand: vi.fn(), + quiesceWorkspace: vi.fn(), + syncWorkspace: vi.fn(), + reconcileWorkspace: vi.fn(), + stop: vi.fn(), + }; + const provider = createWorkerSessionTurnPlacementProvider({ + placements, + environments: { + ...unusedEnvironments(), + get: attachedEnvironment, + acquireTurnCredential: async () => credential(), + startTunnel: async () => tunnel, + }, + }); + const input = turn("launch-tool-negotiation"); + try { + await expect( + provider.executeTurn({ ...sessionTarget, runId: input.runId }, input, vi.fn()), + ).rejects.toBeInstanceOf(WorkerRunnerCapacityError); + expect(launchTurn).toHaveBeenCalledOnce(); + const request = launchTurn.mock.calls[0]![0]; + expect(parseWorkerLaunchPlan(request.plan)).toEqual(request.plan); + const allowed = request.plan.assignment.toolAuthority.allowedToolNames; + expect(allowed.length).toBeGreaterThan(0); + expect(allowed.filter((name) => !launchToolNames.includes(name))).toEqual([]); + expect(allowed.includes("presence")).toBe(declared); + expect(authorize).toHaveBeenCalledExactlyOnceWith(request.turnClaim, allowed); + } finally { + authorize.mockRestore(); + input.preparedRunAdmission.close(); + } + }, + ); + it.each(["current", "cancel"] as const)( "waits for execution-start settlement before new-turn work (%s)", async (change) => { @@ -285,6 +345,7 @@ describe("worker turn execution", () => { ownerEpoch: OWNER_EPOCH, launchTurn, measureLaunchTurn, + readLaunchToolNames, runWorkspaceCommand: vi.fn(), syncWorkspace: vi.fn(), stop: vi.fn(), @@ -365,6 +426,7 @@ describe("worker turn execution", () => { ownerEpoch: OWNER_EPOCH, launchTurn, measureLaunchTurn: measure, + readLaunchToolNames, runWorkspaceCommand: vi.fn(), quiesceWorkspace: vi.fn(), syncWorkspace: vi.fn(), @@ -459,6 +521,7 @@ describe("worker turn execution", () => { ownerEpoch: OWNER_EPOCH, launchTurn, measureLaunchTurn, + readLaunchToolNames, runWorkspaceCommand: vi.fn(), quiesceWorkspace: vi.fn(), syncWorkspace: vi.fn(), diff --git a/src/gateway/worker-environments/worker-turn-execution.ts b/src/gateway/worker-environments/worker-turn-execution.ts index 12982c11a700..c4c3ba9849fb 100644 --- a/src/gateway/worker-environments/worker-turn-execution.ts +++ b/src/gateway/worker-environments/worker-turn-execution.ts @@ -189,6 +189,9 @@ export async function executeWorkerTurn( ...(turn.abortSignal ? { signal: turn.abortSignal } : {}), timeoutMs: turn.timeoutMs, }); + if (!tunnel.launchTurn) { + throw new Error("Worker tunnel does not support worker turns"); + } const portalAvailable = Boolean(environment.nodeDeviceId) && environment.sshEndpoint === null && @@ -196,6 +199,7 @@ export async function executeWorkerTurn( placement.environmentId, placement.activeOwnerEpoch, )) === true; + const launchToolNames = await tunnel.readLaunchToolNames(); const reasoning = resolveProviderThinkingLevel({ provider: modelRef.provider, model: modelRef.model, @@ -221,6 +225,7 @@ export async function executeWorkerTurn( modelRef, turn, portalAvailable, + launchToolNames, }); params.placements.authorizeWorkerTurnTools(params.turnClaim, toolAuthority.allowedToolNames); const { operationalRunInstance, runtimeIdentity, assertActive, takeFinishingOutcome } = @@ -385,9 +390,6 @@ export async function executeWorkerTurn( } // Project the wire handshake; the receipt also carries storage-only provenance. const { bundleHash, openclawVersion, protocolFeatures } = bootstrapReceipt; - if (!tunnel.launchTurn) { - throw new Error("Worker tunnel does not support worker turns"); - } // Presence belongs to the Gateway; workers cannot read its process-local node registry. const requesterProfileId = readRunOperatorAuthority(turn)?.profileId; await prepareActiveNodeContext(requesterProfileId); diff --git a/src/gateway/worker-environments/worker-turn-launcher-computer.test.ts b/src/gateway/worker-environments/worker-turn-launcher-computer.test.ts index 507667e22899..7fa1d1c1e326 100644 --- a/src/gateway/worker-environments/worker-turn-launcher-computer.test.ts +++ b/src/gateway/worker-environments/worker-turn-launcher-computer.test.ts @@ -39,6 +39,7 @@ import { cleanupWorkerTurnLauncherTest, computerDescriptor, createWorkerSessionTurnPlacementProvider, + readLaunchToolNames, placements, seedActivePlacement, setupWorkerTurnLauncherTest, @@ -108,9 +109,10 @@ describe("worker launch capabilities", () => { { missingFeature: undefined, modelHasVision: true, allowed: true }, { missingFeature: undefined, modelHasVision: false, allowed: false }, { missingFeature: WORKER_COMPUTER_PROTOCOL_FEATURE, modelHasVision: true, allowed: false }, + { modelHasVision: true, supervisorAdmitsComputer: false, allowed: false }, ])( - "grants computer with negotiated features and model vision (missing: $missingFeature, vision: $modelHasVision)", - async ({ missingFeature, modelHasVision, allowed }) => { + "grants computer with negotiated features, supervisor vocabulary, and model vision (%j)", + async ({ missingFeature, modelHasVision, supervisorAdmitsComputer = true, allowed }) => { await seedActivePlacement(); const environment = attachedEnvironment(); if (!missingFeature) { @@ -138,6 +140,10 @@ describe("worker launch capabilities", () => { }); const tunnel: WorkerTunnelHandle = createWorkerTurnTunnel({ launchTurn, + readLaunchToolNames: async () => + (await readLaunchToolNames()).filter( + (name) => supervisorAdmitsComputer || name !== "computer", + ), stageAttachments: vi.fn(async () => {}), quiesceWorkspace: vi.fn(), syncWorkspace: vi.fn(), @@ -165,7 +171,9 @@ describe("worker launch capabilities", () => { ).rejects.toBeInstanceOf(WorkerRunnerCapacityError); expect(launchTurn).toHaveBeenCalledOnce(); expect(tunnel.stageAttachments).toHaveBeenCalledTimes(modelHasVision === true ? 1 : 0); - expect(prepareComputer).toHaveBeenCalledTimes(allowed ? 1 : 0); + expect(prepareComputer).toHaveBeenCalledTimes( + !missingFeature && modelHasVision !== false ? 1 : 0, + ); expect(bind).toHaveBeenCalledTimes(allowed ? 1 : 0); }, ); diff --git a/src/gateway/worker-environments/worker-turn-launcher.test-support.ts b/src/gateway/worker-environments/worker-turn-launcher.test-support.ts index 02e8fc8c2888..21b05a0e5130 100644 --- a/src/gateway/worker-environments/worker-turn-launcher.test-support.ts +++ b/src/gateway/worker-environments/worker-turn-launcher.test-support.ts @@ -32,6 +32,7 @@ import { type OpenClawTestState, } from "../../test-utils/openclaw-test-state.js"; import type { WorkerComputerLaunchDescriptor } from "../../worker/launch-descriptor.js"; +import { WORKER_TOOL_NAMES } from "../../worker/tool-authority.js"; import type { MintedWorkerCredential } from "./credential.js"; import { measureNodeWorkerLaunchBytes } from "./node-launch-adapter.js"; import type { @@ -66,6 +67,9 @@ const BUNDLE_HASH = "a".repeat(64); export const MANIFEST_REF = `sha256:${"b".repeat(64)}`; const HOST_KEY = [["ssh", "ed25519"].join("-"), "AAAA"].join(" "); +export const readLaunchToolNames: WorkerTurnTunnelHandle["readLaunchToolNames"] = async () => + WORKER_TOOL_NAMES; + export const measureLaunchTurn: WorkerTurnTunnelHandle["measureLaunchTurn"] = (plan, claim) => measureNodeWorkerLaunchBytes("fixture-node", { environmentSession: 1, @@ -89,6 +93,7 @@ export function createWorkerTurnTunnel< resume: vi.fn(async () => {}), })), measureLaunchTurn, + readLaunchToolNames, syncWorkspace: vi.fn(async () => { throw new Error("unexpected workspace sync"); }), diff --git a/src/gateway/worker-environments/worker-turn-run-owner.test.ts b/src/gateway/worker-environments/worker-turn-run-owner.test.ts index 3e4d358513dd..389431c15eb8 100644 --- a/src/gateway/worker-environments/worker-turn-run-owner.test.ts +++ b/src/gateway/worker-environments/worker-turn-run-owner.test.ts @@ -35,6 +35,7 @@ import { createWorkerSessionTurnPlacementProvider, credential, measureLaunchTurn, + readLaunchToolNames, placements, seedActivePlacement, setupWorkerTurnLauncherTest, @@ -73,6 +74,7 @@ describe("cloud worker run ownership", () => { reconcileWorkspace: vi.fn(), stop: vi.fn(), measureLaunchTurn, + readLaunchToolNames, launchTurn: async (request) => { request.onDispatchReady?.(); workerSignal = request.signal; diff --git a/src/gateway/worker-environments/worker-turn-shutdown.test.ts b/src/gateway/worker-environments/worker-turn-shutdown.test.ts index 53ef9d8a2ff2..395a23304018 100644 --- a/src/gateway/worker-environments/worker-turn-shutdown.test.ts +++ b/src/gateway/worker-environments/worker-turn-shutdown.test.ts @@ -69,6 +69,7 @@ it("accepts an interrupted worker's completed edit before a fresh turn reuses it environmentId: ENVIRONMENT_ID, ownerEpoch: OWNER_EPOCH, measureLaunchTurn: fixture.measureLaunchTurn, + readLaunchToolNames: fixture.readLaunchToolNames, launchTurn, runWorkspaceCommand: unexpected, syncWorkspace: unexpected, diff --git a/src/gateway/worker-environments/worker-turn-trajectory.test.ts b/src/gateway/worker-environments/worker-turn-trajectory.test.ts index 25c3317a640b..7de9a9faf698 100644 --- a/src/gateway/worker-environments/worker-turn-trajectory.test.ts +++ b/src/gateway/worker-environments/worker-turn-trajectory.test.ts @@ -26,6 +26,7 @@ import { createWorkerSessionTurnPlacementProvider, credential, measureLaunchTurn, + readLaunchToolNames, placements, seedActivePlacement, sessionTarget, @@ -113,6 +114,7 @@ describe("worker turn trajectory authority", () => { ownerEpoch: OWNER_EPOCH, launchTurn, measureLaunchTurn, + readLaunchToolNames, runWorkspaceCommand: vi.fn(), quiesceWorkspace: vi.fn(), syncWorkspace: vi.fn(), diff --git a/src/infra/node-runner-inventory.ts b/src/infra/node-runner-inventory.ts index b0d135587aa0..724bb0d0a75c 100644 --- a/src/infra/node-runner-inventory.ts +++ b/src/infra/node-runner-inventory.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { WORKER_BUNDLE_PREWARM_VERSION } from "../../packages/gateway-protocol/src/schema/worker-admission.js"; import { parseWorkerSlotSummary } from "../shared/node-list-parse.js"; import { workerProtocolObject } from "../worker/protocol-record.js"; +import { WORKER_TOOL_NAMES, type WorkerToolName } from "../worker/tool-authority.js"; export const NODE_RUNNER_INVENTORY_UPDATE_METHOD = "node.runnerInventory.update"; export const NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE = "node-worker-supervisor-v6"; @@ -20,6 +21,23 @@ export const NODE_WORKER_ENVIRONMENT_SESSION_VERSION = 1; export const NODE_WORKER_STATUS_WAIT_VERSION = 1; export const NODE_WORKER_PREPARED_WORKSPACE_VERSION = 1; +// Supervisors predating launchToolNames admit this closed vocabulary: OpenClaw 2026.9.6 +// is the only published release that passes the worker-turn launch gate. Retire with the next dialect. +const LEGACY_NODE_WORKER_LAUNCH_TOOL_NAMES = Object.freeze([ + "read", + "write", + "edit", + "apply_patch", + "exec", + "process", + "browser", + "computer", + "skill_workshop", + "sessions_spawn", + "sessions_send", + "portal", +] satisfies WorkerToolName[]); + export const NODE_RUNNER_UPDATE_REQUIRED_ISSUE = { code: "update-required", action: "update-and-reconnect", @@ -36,6 +54,15 @@ const CapacitySnapshot = z.transform((value, context) => { } return capacity; }); +// Unknown names are ignored so newer nodes can still declare to this Gateway. +const LaunchToolNames = z + .array(z.string().min(1).max(64)) + .max(64) + .refine((names) => new Set(names).size === names.length) + .transform((names): readonly WorkerToolName[] => { + const declared = new Set(names); + return WORKER_TOOL_NAMES.filter((name) => declared.has(name)); + }); const WorkerHost = z.union([ workerProtocolObject({ enabled: z.literal(false) }), workerProtocolObject({ @@ -49,6 +76,7 @@ const WorkerHost = z.union([ statusWait: z.literal(NODE_WORKER_STATUS_WAIT_VERSION).optional(), preparedWorkspace: z.literal(NODE_WORKER_PREPARED_WORKSPACE_VERSION).optional(), capturedExecPolicy: z.literal(true).optional(), + launchToolNames: LaunchToolNames.optional(), }).refine((host) => host.bundleStatus === undefined || host.bundleRetention !== undefined), ]); export type NodeWorkerCapacitySnapshot = Readonly>; @@ -126,3 +154,11 @@ export function resolveNodeWorkerExecutionIssue( ? NODE_RUNNER_UPDATE_REQUIRED_ISSUE : undefined; } + +export function resolveNodeWorkerLaunchToolNames( + workerHost: NodeWorkerHostDeclaration | undefined, +): readonly WorkerToolName[] { + return ( + (workerHost?.enabled && workerHost.launchToolNames) || LEGACY_NODE_WORKER_LAUNCH_TOOL_NAMES + ); +} diff --git a/src/node-host/connection.test.ts b/src/node-host/connection.test.ts index cf3ecf8a0bb2..052a585d6f99 100644 --- a/src/node-host/connection.test.ts +++ b/src/node-host/connection.test.ts @@ -3,9 +3,12 @@ import { GATEWAY_SERVER_CAPS } from "../../packages/gateway-protocol/src/schema/ import { createDeferred } from "../../test/helpers/promise.js"; import { NODE_RUNNER_INVENTORY_UPDATE_METHOD, + NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE, parseNodeRunnerInventoryDeclaration, + resolveNodeWorkerLaunchToolNames, } from "../infra/node-runner-inventory.js"; import { NODE_HOST_STATS_EVENT, NODE_HOST_STATS_INTERVAL_MS } from "../shared/node-host-stats.js"; +import { WORKER_TOOL_NAMES } from "../worker/tool-authority.js"; import { startNodeHostConnection } from "./connection.js"; import * as hostStats from "./host-stats.js"; @@ -22,6 +25,7 @@ it("negotiates optional worker capabilities per connection without widening olde capabilities: supported ? [ GATEWAY_SERVER_CAPS.NODE_WORKER_CAPTURED_EXEC_POLICY, + GATEWAY_SERVER_CAPS.NODE_WORKER_LAUNCH_TOOL_NAMES, GATEWAY_SERVER_CAPS.NODE_WORKER_STATUS_WAIT, ] : [], @@ -36,7 +40,9 @@ it("negotiates optional worker capabilities per connection without widening olde enabled: true, capacity: { total: 2, available: 2 }, bundlePrewarm: 1, - ...(supported ? { capturedExecPolicy: true, statusWait: 1 } : {}), + ...(supported + ? { capturedExecPolicy: true, launchToolNames: [...WORKER_TOOL_NAMES], statusWait: 1 } + : {}), }, }); expect(parseNodeRunnerInventoryDeclaration(declaration)).toEqual(declaration); @@ -47,6 +53,89 @@ it("negotiates optional worker capabilities per connection without widening olde } }); +it("keeps the published 2026.9.6 supervisor launch vocabulary when no names are declared", () => { + const declaration = parseNodeRunnerInventoryDeclaration({ + protocolFeatures: [NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE], + workerHost: { + enabled: true, + capacity: { total: 2, available: 2 }, + environmentSession: 1, + capturedExecPolicy: true, + }, + }); + if (!declaration || !("workerHost" in declaration)) { + throw new Error("Expected the published supervisor declaration to parse"); + } + const names = resolveNodeWorkerLaunchToolNames(declaration.workerHost); + expect(names).toEqual([ + "read", + "write", + "edit", + "apply_patch", + "exec", + "process", + "browser", + "computer", + "skill_workshop", + "sessions_spawn", + "sessions_send", + "portal", + ]); + expect(names).not.toContain("presence"); + expect(resolveNodeWorkerLaunchToolNames(undefined)).toEqual(names); + expect(resolveNodeWorkerLaunchToolNames({ enabled: false })).toEqual(names); +}); + +it.each([ + { + declared: ["presence", "future_tool", "portal", "read"], + expected: ["read", "portal", "presence"], + }, + { declared: ["future_tool"], expected: [] }, + { declared: [], expected: [] }, +])( + "normalizes declared launch names $declared without rejecting future tools", + ({ declared, expected }) => { + const declaration = parseNodeRunnerInventoryDeclaration({ + protocolFeatures: [NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE], + workerHost: { + enabled: true, + capacity: { total: 2, available: 2 }, + bundlePrewarm: 1, + bundleRetention: 1, + bundleStatus: 1, + portalStream: 1, + environmentSession: 1, + preparedWorkspace: 1, + capturedExecPolicy: true, + launchToolNames: declared, + }, + }); + if (!declaration || !("workerHost" in declaration)) { + throw new Error("Expected the declared launch names to parse"); + } + expect(declaration.workerHost).toMatchObject({ launchToolNames: expected }); + expect(resolveNodeWorkerLaunchToolNames(declaration.workerHost)).toEqual(expected); + }, +); + +it.each([ + { reason: "non-array", value: "read" }, + { reason: "duplicate", value: ["read", "read"] }, + { reason: "duplicate unknown", value: ["future_tool", "future_tool"] }, + { reason: "non-string", value: [1] }, + { reason: "empty name", value: [""] }, + { reason: "oversized name", value: ["x".repeat(65)] }, + { reason: "oversized array", value: Array.from({ length: 65 }, (_, index) => `tool_${index}`) }, +])("rejects the whole worker declaration for $reason launch names", ({ value }) => { + expect( + parseNodeRunnerInventoryDeclaration({ + protocolFeatures: [NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE], + workerHost: { enabled: true, capacity: { total: 2, available: 2 }, launchToolNames: value }, + }), + ).toBeNull(); +}); + function startConnectionFixture(workerHostingEnabled = false, preparedWorkspacesEnabled = false) { const request = vi.fn().mockResolvedValue({ ok: true, handled: false }); const runtime = { diff --git a/src/node-host/connection.ts b/src/node-host/connection.ts index 2cb5acf759f0..653da747d980 100644 --- a/src/node-host/connection.ts +++ b/src/node-host/connection.ts @@ -16,6 +16,7 @@ import { } from "../infra/node-runner-inventory.js"; import { redactSensitiveText } from "../logging/redact.js"; import { NODE_HOST_STATS_EVENT, NODE_HOST_STATS_INTERVAL_MS } from "../shared/node-host-stats.js"; +import { WORKER_TOOL_NAMES } from "../worker/tool-authority.js"; import type { NodeHostClient } from "./client.js"; import { sampleNodeHostStats } from "./host-stats.js"; import { buildNodeEventParams } from "./node-event-params.js"; @@ -345,6 +346,9 @@ export function startNodeHostConnection({ ...(gatewayCapabilities.has(GATEWAY_SERVER_CAPS.NODE_WORKER_CAPTURED_EXEC_POLICY) ? { capturedExecPolicy: true } : {}), + ...(gatewayCapabilities.has(GATEWAY_SERVER_CAPS.NODE_WORKER_LAUNCH_TOOL_NAMES) + ? { launchToolNames: [...WORKER_TOOL_NAMES] } + : {}), } : { enabled: false }, }, diff --git a/ui/src/e2e/worker-initial-setup.real-gateway.e2e.test.ts b/ui/src/e2e/worker-initial-setup.real-gateway.e2e.test.ts index f9a009fcd19d..d7874c17ba1e 100644 --- a/ui/src/e2e/worker-initial-setup.real-gateway.e2e.test.ts +++ b/ui/src/e2e/worker-initial-setup.real-gateway.e2e.test.ts @@ -26,6 +26,7 @@ import { ENVIRONMENT_ID, MANIFEST_REF, measureLaunchTurn, + readLaunchToolNames, OWNER_EPOCH, unusedEnvironments, } from "../../../src/gateway/worker-environments/worker-turn-launcher.test-support.js"; @@ -162,6 +163,7 @@ suite.define(() => { timeoutMs: 5000, }), measureLaunchTurn, + readLaunchToolNames, launchTurn: async (request) => { request.onDispatchReady?.(); launched.push(request.turnClaim.runId);