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);