mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
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:
parent
db197f9a8f
commit
87702484d1
31 changed files with 319 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"]>(),
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue