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.
This commit is contained in:
Peter Steinberger 2026-09-28 14:58:24 -07:00 • committed by GitHub
parent db197f9a8f
commit 87702484d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 319 additions and 22 deletions

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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,
});

View file

@ -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 },
}
: {}),

View file

@ -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],
};

View file

@ -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,

View file

@ -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(),

View file

@ -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(),

View file

@ -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(),

View file

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

View file

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

View file

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

View file

@ -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<WorkerTurnTunnelHandle["launchTurn"]>(),
runWorkspaceCommand: vi.fn<WorkerTurnTunnelHandle["runWorkspaceCommand"]>(),
quiesceWorkspace: vi.fn<WorkerTurnTunnelHandle["quiesceWorkspace"]>(),

View file

@ -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<SpawnResult>;
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<readonly WorkerToolName[]>;
launchTurn(request: WorkerTurnLaunchRequest): Promise<SpawnResult>;
};

View file

@ -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<PreparedWorkerComputer | undefined> | 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,
});

View file

@ -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<SessionPlacementTurnParams> = {}): SessionPlace
function authority(overrides: Partial<SessionPlacementTurnParams> = {}, 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<SessionPlacementTurnParams> = {}, portalAv
function resolvedAuthority(overrides: Partial<SessionPlacementTurnParams> = {}) {
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"],

View file

@ -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,
};
}

View file

@ -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: () => ({

View file

@ -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,

View file

@ -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<NonNullable<WorkerTunnelHandle["launchTurn"]>>(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(),

View file

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

View file

@ -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);
},
);

View file

@ -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");
}),

View file

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

View file

@ -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,

View file

@ -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(),

View file

@ -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<string>(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<z.infer<typeof CapacitySnapshot>>;
@ -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
);
}

View file

@ -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 = {

View file

@ -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 },
},

View file

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