mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(worker): preserve scheduled tool authority
This commit is contained in:
+8
-3
@@ -21,13 +21,17 @@ admits as the dedicated `worker` role.
|
||||
|
||||
The command reads exactly one bounded JSON launch envelope from standard input.
|
||||
The envelope carries the local socket location, minted worker credential, bundle
|
||||
and protocol identity, owner epoch, and the single assigned session and turn.
|
||||
and protocol identity, owner epoch, the single assigned session and turn, and the
|
||||
exact worker-local tool names authorized for that turn. The Gateway resolves this
|
||||
final tool set from current policy before handoff; raw config and scheduled-owner
|
||||
identity never enter the worker envelope.
|
||||
The credential is never accepted through command-line arguments, and this page
|
||||
intentionally provides no credential or hand-authored envelope example.
|
||||
|
||||
Admission fails closed if the envelope is invalid, the credential is rejected,
|
||||
the bundle or protocol features do not match, or the session and owner epoch are
|
||||
no longer current. Operators should start workers through the cloud worker
|
||||
no longer current. Missing, duplicate, or unknown tool names also invalidate the
|
||||
envelope. Operators should start workers through the cloud worker
|
||||
orchestrator rather than invoke this entry point directly.
|
||||
|
||||
## Runtime boundary
|
||||
@@ -35,7 +39,8 @@ orchestrator rather than invoke this entry point directly.
|
||||
The process runs the normal embedded agent loop with a restricted backend:
|
||||
|
||||
- The `read`, `write`, `edit`, `apply_patch`, `exec`, and `process` coding tools
|
||||
run locally in the worker workspace.
|
||||
run locally in the worker workspace when present in the Gateway-issued turn
|
||||
authority. An empty authority runs the model with no tools.
|
||||
- Model calls use the gateway inference proxy. No local model auth profile is
|
||||
loaded.
|
||||
- Transcript writes use the gateway transcript-commit RPC.
|
||||
|
||||
@@ -119,6 +119,7 @@ Placement moves through a durable state machine (`local → requested → provis
|
||||
## Security model
|
||||
|
||||
- **Closed worker ingress.** Workers speak a dedicated protocol on the tunneled socket with a closed method allowlist — a worker cannot call operator RPCs.
|
||||
- **Gateway-owned tool authority.** Before every turn, the Gateway projects current profile, provider, agent, group, sender, sandbox, delegation, inherited, and runtime-cap policy over the worker's fixed coding-tool catalog. The launch envelope carries only that final closed-vocabulary subset. Explicitly capped scheduled turns reuse their trusted owner-group context without sending that identity to the box or reapplying a fresh sender overlay. Tools outside the worker catalog remain unavailable; an empty result runs with no tools.
|
||||
- **Minted credentials, hashed at rest.** Each dispatch mints a worker credential; the Gateway stores only its hash. Credential rotation and owner-epoch fencing guarantee at most one live owner per session — a stale worker that reconnects is fenced, never merged.
|
||||
- **Host-key pinning.** The provider must surface the box's SSH host key at provision time; bootstrap connects with strict pinning and fails closed without it.
|
||||
- **No standing model, forge, or cloud credentials on the box.** Model auth stays on the Gateway (inference travels by `{provider, model}` reference), workspace git commits are authored without forge credentials, and Crabbox AWS lease metadata is checked authoritatively for an instance role before setup. Keep setup commands credential-free too.
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from "../sessions/session-key-utils.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js";
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { resolveProviderToolPolicy } from "./provider-tool-policy.js";
|
||||
import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js";
|
||||
import type { SandboxToolPolicy } from "./sandbox.js";
|
||||
@@ -142,7 +141,10 @@ export function resolveInheritedToolPolicyForSession(
|
||||
}
|
||||
|
||||
/** Filter runtime tools by sandbox allow/deny policy. */
|
||||
export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolPolicy) {
|
||||
export function filterToolsByPolicy<TTool extends { name: string }>(
|
||||
tools: TTool[],
|
||||
policy?: SandboxToolPolicy,
|
||||
): TTool[] {
|
||||
if (!policy) {
|
||||
return tools;
|
||||
}
|
||||
|
||||
+35
-96
@@ -70,6 +70,10 @@ import {
|
||||
type ResolvedConversationCapabilityProfile,
|
||||
} from "./conversation-capability-profile.js";
|
||||
import type { ConversationRecallContext } from "./conversation-recall.types.js";
|
||||
import {
|
||||
buildConversationToolPolicyPipelineSteps,
|
||||
resolveConversationToolPolicies,
|
||||
} from "./conversation-tool-policy-pipeline.js";
|
||||
import type { OpenClawCodingToolConstructionPlan } from "./core-tool-factory-descriptors.js";
|
||||
import { applyDelegationCapability, type DelegationCapability } from "./delegation-capability.js";
|
||||
import { resolveImageSanitizationLimits } from "./image-sanitization.js";
|
||||
@@ -92,14 +96,10 @@ import { createToolFsPolicy, resolveToolFsConfig } from "./tool-fs-policy.js";
|
||||
import { resolveToolLoopDetectionConfig } from "./tool-loop-detection-config.js";
|
||||
import { buildDeclaredToolAllowlistContext } from "./tool-policy-declared-context.js";
|
||||
import { isToolAllowedByPolicies } from "./tool-policy-match.js";
|
||||
import {
|
||||
applyToolPolicyPipeline,
|
||||
buildDefaultToolPolicyPipelineSteps,
|
||||
} from "./tool-policy-pipeline.js";
|
||||
import { applyToolPolicyPipeline } from "./tool-policy-pipeline.js";
|
||||
import {
|
||||
expandToolGroups,
|
||||
hasRestrictiveAllowPolicy,
|
||||
mergeAlsoAllowPolicy,
|
||||
normalizeToolName,
|
||||
replaceWithEffectiveToolAllowlist,
|
||||
} from "./tool-policy.js";
|
||||
@@ -529,25 +529,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
trustedInternalHandoff: options?.trustedInternalHandoff,
|
||||
scheduledToolPolicy: options?.scheduledToolPolicy,
|
||||
});
|
||||
const {
|
||||
agentId,
|
||||
globalPolicy,
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
profile,
|
||||
providerProfile,
|
||||
profilePolicy,
|
||||
providerProfilePolicy,
|
||||
profileAlsoAllow,
|
||||
providerProfileAlsoAllow,
|
||||
groupPolicy,
|
||||
senderPolicy,
|
||||
subagentPolicy,
|
||||
inheritedToolPolicy,
|
||||
runtimePluginToolGrant,
|
||||
runtimeToolPolicyForInheritance,
|
||||
} = capabilityProfile.policy;
|
||||
const { agentId, runtimePluginToolGrant } = capabilityProfile.policy;
|
||||
|
||||
const enableHeartbeatTool =
|
||||
options?.enableHeartbeatTool === true ||
|
||||
@@ -565,9 +547,6 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
TOOL_CALL_RAW_TOOL_NAME,
|
||||
]
|
||||
: [];
|
||||
const mergeToolSearchControlAllowlist = <TPolicy extends { allow?: string[] }>(
|
||||
policy: TPolicy | undefined,
|
||||
) => mergeAlsoAllowPolicy(policy, toolSearchControlAllowlist);
|
||||
const runtimeToolAllowlistIncludesMessage = expandToolGroups(
|
||||
options?.runtimeToolAllowlist ?? [],
|
||||
).some((toolName) => {
|
||||
@@ -587,14 +566,11 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
...(forceHeartbeatTool ? [HEARTBEAT_RESPONSE_TOOL_NAME] : []),
|
||||
...toolSearchControlAllowlist,
|
||||
];
|
||||
const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, [
|
||||
...(profileAlsoAllow ?? []),
|
||||
...runtimeProfileAlsoAllow,
|
||||
]);
|
||||
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(providerProfilePolicy, [
|
||||
...(providerProfileAlsoAllow ?? []),
|
||||
...runtimeProfileAlsoAllow,
|
||||
]);
|
||||
const conversationToolPolicies = resolveConversationToolPolicies({
|
||||
capabilityProfile,
|
||||
additionalProfileAllow: runtimeProfileAlsoAllow,
|
||||
additionalPolicyAllow: toolSearchControlAllowlist,
|
||||
});
|
||||
// Prefer sessionKey for process isolation scope to prevent cross-session process visibility/killing.
|
||||
// Fallback to agentId if no sessionKey is available (e.g. legacy or global contexts).
|
||||
const scopeKey = resolveProcessToolScopeKey({
|
||||
@@ -603,29 +579,18 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
sessionId: options?.sessionId,
|
||||
agentId,
|
||||
});
|
||||
const globalPolicyWithToolSearchControls = mergeToolSearchControlAllowlist(globalPolicy);
|
||||
const globalProviderPolicyWithToolSearchControls =
|
||||
mergeToolSearchControlAllowlist(globalProviderPolicy);
|
||||
const agentPolicyWithToolSearchControls = mergeToolSearchControlAllowlist(agentPolicy);
|
||||
const agentProviderPolicyWithToolSearchControls =
|
||||
mergeToolSearchControlAllowlist(agentProviderPolicy);
|
||||
const groupPolicyWithToolSearchControls = mergeToolSearchControlAllowlist(groupPolicy);
|
||||
const senderPolicyWithToolSearchControls = mergeToolSearchControlAllowlist(senderPolicy);
|
||||
const sandboxToolPolicyWithToolSearchControls =
|
||||
mergeToolSearchControlAllowlist(sandboxToolPolicy);
|
||||
const subagentPolicyWithToolSearchControls = mergeToolSearchControlAllowlist(subagentPolicy);
|
||||
const allowBackground = isToolAllowedByPolicies("process", [
|
||||
profilePolicyWithAlsoAllow,
|
||||
providerProfilePolicyWithAlsoAllow,
|
||||
globalPolicyWithToolSearchControls,
|
||||
globalProviderPolicyWithToolSearchControls,
|
||||
agentPolicyWithToolSearchControls,
|
||||
agentProviderPolicyWithToolSearchControls,
|
||||
groupPolicyWithToolSearchControls,
|
||||
senderPolicyWithToolSearchControls,
|
||||
sandboxToolPolicyWithToolSearchControls,
|
||||
subagentPolicyWithToolSearchControls,
|
||||
inheritedToolPolicy,
|
||||
conversationToolPolicies.profilePolicy,
|
||||
conversationToolPolicies.providerProfilePolicy,
|
||||
conversationToolPolicies.globalPolicy,
|
||||
conversationToolPolicies.globalProviderPolicy,
|
||||
conversationToolPolicies.agentPolicy,
|
||||
conversationToolPolicies.agentProviderPolicy,
|
||||
conversationToolPolicies.groupPolicy,
|
||||
conversationToolPolicies.senderPolicy,
|
||||
conversationToolPolicies.sandboxPolicy,
|
||||
conversationToolPolicies.subagentPolicy,
|
||||
conversationToolPolicies.inheritedToolPolicy,
|
||||
]);
|
||||
options?.recordToolPrepStage?.("tool-policy");
|
||||
const execConfig = resolveExecToolConfig({ cfg: options?.config, agentId });
|
||||
@@ -1095,45 +1060,19 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
tools: toolsForModelProvider,
|
||||
toolMeta: (tool) => getPluginToolMeta(tool),
|
||||
warn: logWarn,
|
||||
steps: [
|
||||
...buildDefaultToolPolicyPipelineSteps({
|
||||
profilePolicy: profilePolicyWithAlsoAllow,
|
||||
profile,
|
||||
profileUnavailableCoreWarningAllowlist: profilePolicy?.allow,
|
||||
providerProfilePolicy: providerProfilePolicyWithAlsoAllow,
|
||||
providerProfile,
|
||||
providerProfileUnavailableCoreWarningAllowlist: providerProfilePolicy?.allow,
|
||||
globalPolicy: globalPolicyWithToolSearchControls,
|
||||
globalProviderPolicy: globalProviderPolicyWithToolSearchControls,
|
||||
agentPolicy: agentPolicyWithToolSearchControls,
|
||||
agentProviderPolicy: agentProviderPolicyWithToolSearchControls,
|
||||
groupPolicy: groupPolicyWithToolSearchControls,
|
||||
senderPolicy: senderPolicyWithToolSearchControls,
|
||||
agentId,
|
||||
unavailableCoreToolReason,
|
||||
}),
|
||||
{
|
||||
policy: sandboxToolPolicyWithToolSearchControls,
|
||||
label: "sandbox tools.allow",
|
||||
unavailableCoreToolReason,
|
||||
},
|
||||
{
|
||||
policy: ownerOnlyCoreToolPolicy,
|
||||
label: "gateway sender owner-only tools",
|
||||
unavailableCoreToolReason,
|
||||
},
|
||||
{
|
||||
policy: subagentPolicyWithToolSearchControls,
|
||||
label: "subagent tools.allow",
|
||||
unavailableCoreToolReason,
|
||||
},
|
||||
{
|
||||
policy: runtimeToolPolicyForInheritance,
|
||||
label: "runtime tools.allow",
|
||||
unavailableCoreToolReason,
|
||||
},
|
||||
{ policy: inheritedToolPolicy, label: "inherited tools", unavailableCoreToolReason },
|
||||
],
|
||||
steps: buildConversationToolPolicyPipelineSteps({
|
||||
capabilityProfile,
|
||||
policies: conversationToolPolicies,
|
||||
additionalStepsAfterSandbox: [
|
||||
{
|
||||
policy: ownerOnlyCoreToolPolicy,
|
||||
label: "gateway sender owner-only tools",
|
||||
unavailableCoreToolReason,
|
||||
},
|
||||
],
|
||||
includeRuntimeToolPolicy: true,
|
||||
unavailableCoreToolReason,
|
||||
}),
|
||||
auditLogLevel: options?.toolPolicyAuditLogLevel,
|
||||
declaredToolAllowlist: buildDeclaredToolAllowlistContext({
|
||||
config: options?.config,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { ResolvedConversationCapabilityProfile } from "./conversation-capability-profile.js";
|
||||
import {
|
||||
applyToolPolicyPipeline,
|
||||
buildDefaultToolPolicyPipelineSteps,
|
||||
type ToolPolicyPipelineStep,
|
||||
} from "./tool-policy-pipeline.js";
|
||||
import { mergeAlsoAllowPolicy, type ToolPolicyLike } from "./tool-policy.js";
|
||||
|
||||
export type ResolvedConversationToolPolicies = {
|
||||
profilePolicy?: ToolPolicyLike;
|
||||
providerProfilePolicy?: ToolPolicyLike;
|
||||
globalPolicy?: ToolPolicyLike;
|
||||
globalProviderPolicy?: ToolPolicyLike;
|
||||
agentPolicy?: ToolPolicyLike;
|
||||
agentProviderPolicy?: ToolPolicyLike;
|
||||
groupPolicy?: ToolPolicyLike;
|
||||
senderPolicy?: ToolPolicyLike;
|
||||
sandboxPolicy?: ToolPolicyLike;
|
||||
subagentPolicy?: ToolPolicyLike;
|
||||
runtimeToolPolicy?: ToolPolicyLike;
|
||||
inheritedToolPolicy?: ToolPolicyLike;
|
||||
};
|
||||
|
||||
function mergePolicyAllowlist<TPolicy extends ToolPolicyLike>(
|
||||
policy: TPolicy | undefined,
|
||||
alsoAllow: readonly string[] | undefined,
|
||||
): TPolicy | undefined {
|
||||
return mergeAlsoAllowPolicy(policy, alsoAllow ? [...alsoAllow] : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the shared policy layers once so local and remote fixed tool surfaces cannot
|
||||
* diverge on profile `alsoAllow`, sender, sandbox, delegation, or runtime-cap semantics.
|
||||
*/
|
||||
export function resolveConversationToolPolicies(params: {
|
||||
capabilityProfile: ResolvedConversationCapabilityProfile;
|
||||
additionalProfileAllow?: readonly string[];
|
||||
additionalPolicyAllow?: readonly string[];
|
||||
}): ResolvedConversationToolPolicies {
|
||||
const policy = params.capabilityProfile.policy;
|
||||
const profileAllow = [
|
||||
...(policy.profileAlsoAllow ?? []),
|
||||
...(params.additionalProfileAllow ?? []),
|
||||
];
|
||||
const providerProfileAllow = [
|
||||
...(policy.providerProfileAlsoAllow ?? []),
|
||||
...(params.additionalProfileAllow ?? []),
|
||||
];
|
||||
return {
|
||||
profilePolicy: mergePolicyAllowlist(policy.profilePolicy, profileAllow),
|
||||
providerProfilePolicy: mergePolicyAllowlist(policy.providerProfilePolicy, providerProfileAllow),
|
||||
globalPolicy: mergePolicyAllowlist(policy.globalPolicy, params.additionalPolicyAllow),
|
||||
globalProviderPolicy: mergePolicyAllowlist(
|
||||
policy.globalProviderPolicy,
|
||||
params.additionalPolicyAllow,
|
||||
),
|
||||
agentPolicy: mergePolicyAllowlist(policy.agentPolicy, params.additionalPolicyAllow),
|
||||
agentProviderPolicy: mergePolicyAllowlist(
|
||||
policy.agentProviderPolicy,
|
||||
params.additionalPolicyAllow,
|
||||
),
|
||||
groupPolicy: mergePolicyAllowlist(policy.groupPolicy, params.additionalPolicyAllow),
|
||||
senderPolicy: mergePolicyAllowlist(policy.senderPolicy, params.additionalPolicyAllow),
|
||||
sandboxPolicy: mergePolicyAllowlist(policy.sandboxPolicy, params.additionalPolicyAllow),
|
||||
subagentPolicy: mergePolicyAllowlist(policy.subagentPolicy, params.additionalPolicyAllow),
|
||||
runtimeToolPolicy: policy.runtimeToolPolicyForInheritance,
|
||||
inheritedToolPolicy: policy.inheritedToolPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds the canonical ordered policy pipeline for a resolved conversation. */
|
||||
export function buildConversationToolPolicyPipelineSteps(params: {
|
||||
capabilityProfile: ResolvedConversationCapabilityProfile;
|
||||
policies: ResolvedConversationToolPolicies;
|
||||
additionalStepsAfterSandbox?: ToolPolicyPipelineStep[];
|
||||
includeRuntimeToolPolicy: boolean;
|
||||
unavailableCoreToolReason?: string;
|
||||
}): ToolPolicyPipelineStep[] {
|
||||
const profile = params.capabilityProfile.policy;
|
||||
return [
|
||||
...buildDefaultToolPolicyPipelineSteps({
|
||||
profilePolicy: params.policies.profilePolicy,
|
||||
profile: profile.profile,
|
||||
profileUnavailableCoreWarningAllowlist: profile.profilePolicy?.allow,
|
||||
providerProfilePolicy: params.policies.providerProfilePolicy,
|
||||
providerProfile: profile.providerProfile,
|
||||
providerProfileUnavailableCoreWarningAllowlist: profile.providerProfilePolicy?.allow,
|
||||
globalPolicy: params.policies.globalPolicy,
|
||||
globalProviderPolicy: params.policies.globalProviderPolicy,
|
||||
agentPolicy: params.policies.agentPolicy,
|
||||
agentProviderPolicy: params.policies.agentProviderPolicy,
|
||||
groupPolicy: params.policies.groupPolicy,
|
||||
senderPolicy: params.policies.senderPolicy,
|
||||
agentId: profile.agentId,
|
||||
unavailableCoreToolReason: params.unavailableCoreToolReason,
|
||||
}),
|
||||
{
|
||||
policy: params.policies.sandboxPolicy,
|
||||
label: "sandbox tools.allow",
|
||||
unavailableCoreToolReason: params.unavailableCoreToolReason,
|
||||
},
|
||||
...(params.additionalStepsAfterSandbox ?? []),
|
||||
{
|
||||
policy: params.policies.subagentPolicy,
|
||||
label: "subagent tools.allow",
|
||||
unavailableCoreToolReason: params.unavailableCoreToolReason,
|
||||
},
|
||||
...(params.includeRuntimeToolPolicy
|
||||
? [
|
||||
{
|
||||
policy: params.policies.runtimeToolPolicy,
|
||||
label: "runtime tools.allow",
|
||||
unavailableCoreToolReason: params.unavailableCoreToolReason,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
policy: params.policies.inheritedToolPolicy,
|
||||
label: "inherited tools",
|
||||
unavailableCoreToolReason: params.unavailableCoreToolReason,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Projects a fixed runtime catalog through the exact conversation policy pipeline. */
|
||||
export function projectConversationToolNames<TName extends string>(params: {
|
||||
capabilityProfile: ResolvedConversationCapabilityProfile;
|
||||
toolNames: readonly TName[];
|
||||
warn: (message: string) => void;
|
||||
}): TName[] {
|
||||
const policies = resolveConversationToolPolicies({
|
||||
capabilityProfile: params.capabilityProfile,
|
||||
});
|
||||
const tools = params.toolNames.map((name) => ({ name }));
|
||||
return applyToolPolicyPipeline({
|
||||
tools,
|
||||
toolMeta: () => undefined,
|
||||
warn: params.warn,
|
||||
steps: buildConversationToolPolicyPipelineSteps({
|
||||
capabilityProfile: params.capabilityProfile,
|
||||
policies,
|
||||
includeRuntimeToolPolicy: true,
|
||||
}),
|
||||
}).map((tool) => tool.name);
|
||||
}
|
||||
@@ -5,14 +5,17 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
|
||||
import { getPluginToolMeta } from "../../plugins/tools.js";
|
||||
import type { ResolvedConversationCapabilityProfile } from "../conversation-capability-profile.js";
|
||||
import {
|
||||
buildConversationToolPolicyPipelineSteps,
|
||||
resolveConversationToolPolicies,
|
||||
} from "../conversation-tool-policy-pipeline.js";
|
||||
import { buildDeclaredToolAllowlistContext } from "../tool-policy-declared-context.js";
|
||||
import {
|
||||
applyToolPolicyPipeline,
|
||||
buildDefaultToolPolicyPipelineSteps,
|
||||
type ToolPolicyFilterEvent,
|
||||
type ToolPolicyPipelineStep,
|
||||
} from "../tool-policy-pipeline.js";
|
||||
import { collectExplicitDenylist, mergeAlsoAllowPolicy } from "../tool-policy.js";
|
||||
import { collectExplicitDenylist } from "../tool-policy.js";
|
||||
import type { AnyAgentTool } from "../tools/common.js";
|
||||
|
||||
/**
|
||||
@@ -54,29 +57,7 @@ export function applyFinalEffectiveToolPolicy(
|
||||
"effective tool policy: dropping caller-provided groupId that does not match session-derived group context",
|
||||
);
|
||||
}
|
||||
const {
|
||||
agentId,
|
||||
globalPolicy,
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
profile,
|
||||
providerProfile,
|
||||
profilePolicy,
|
||||
providerProfilePolicy,
|
||||
profileAlsoAllow,
|
||||
providerProfileAlsoAllow,
|
||||
groupPolicy,
|
||||
senderPolicy,
|
||||
sandboxPolicy,
|
||||
subagentPolicy,
|
||||
inheritedToolPolicy,
|
||||
} = capabilityProfile.policy;
|
||||
const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, profileAlsoAllow);
|
||||
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(
|
||||
providerProfilePolicy,
|
||||
providerProfileAlsoAllow,
|
||||
);
|
||||
const policies = resolveConversationToolPolicies({ capabilityProfile });
|
||||
// Suppress unavailable-core-tool warnings on every step of this pass.
|
||||
// `applyToolPolicyPipeline` infers `coreToolNames` from the `tools` array
|
||||
// it's filtering, and this pass only sees the bundled MCP/LSP subset.
|
||||
@@ -87,26 +68,11 @@ export function applyFinalEffectiveToolPolicy(
|
||||
// real diagnostics from the shared warning cache. Genuinely unknown
|
||||
// entries (typos) still surface through the `otherEntries` path in
|
||||
// `applyToolPolicyPipeline`.
|
||||
const pipelineSteps: ToolPolicyPipelineStep[] = [
|
||||
...buildDefaultToolPolicyPipelineSteps({
|
||||
profilePolicy: profilePolicyWithAlsoAllow,
|
||||
profile,
|
||||
profileUnavailableCoreWarningAllowlist: profilePolicy?.allow,
|
||||
providerProfilePolicy: providerProfilePolicyWithAlsoAllow,
|
||||
providerProfile,
|
||||
providerProfileUnavailableCoreWarningAllowlist: providerProfilePolicy?.allow,
|
||||
globalPolicy,
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
groupPolicy,
|
||||
senderPolicy,
|
||||
agentId,
|
||||
}),
|
||||
{ policy: sandboxPolicy, label: "sandbox tools.allow" },
|
||||
{ policy: subagentPolicy, label: "subagent tools.allow" },
|
||||
{ policy: inheritedToolPolicy, label: "inherited tools" },
|
||||
].map((step) => Object.assign({}, step, { suppressUnavailableCoreToolWarning: true }));
|
||||
const pipelineSteps: ToolPolicyPipelineStep[] = buildConversationToolPolicyPipelineSteps({
|
||||
capabilityProfile,
|
||||
policies,
|
||||
includeRuntimeToolPolicy: false,
|
||||
}).map((step) => Object.assign({}, step, { suppressUnavailableCoreToolWarning: true }));
|
||||
return applyToolPolicyPipeline({
|
||||
tools: params.bundledTools,
|
||||
toolMeta: (tool) => getPluginToolMeta(tool),
|
||||
|
||||
@@ -46,11 +46,11 @@ export type ToolPolicyPipelineStep = {
|
||||
};
|
||||
|
||||
/** One policy application, exposed for diagnostics that need exclusion provenance. */
|
||||
export type ToolPolicyFilterEvent = {
|
||||
export type ToolPolicyFilterEvent<TTool extends { name: string } = AnyAgentTool> = {
|
||||
step: ToolPolicyPipelineStep;
|
||||
policy: ToolPolicyLike;
|
||||
before: readonly AnyAgentTool[];
|
||||
after: readonly AnyAgentTool[];
|
||||
before: readonly TTool[];
|
||||
after: readonly TTool[];
|
||||
};
|
||||
|
||||
/** Builds the default profile, provider, agent, group, and sender policy layers. */
|
||||
@@ -132,15 +132,15 @@ export function buildDefaultToolPolicyPipelineSteps(params: {
|
||||
}
|
||||
|
||||
/** Applies configured policy layers to a tool list and emits deduped warnings/audit events. */
|
||||
export function applyToolPolicyPipeline(params: {
|
||||
tools: AnyAgentTool[];
|
||||
toolMeta: (tool: AnyAgentTool) => { pluginId: string } | undefined;
|
||||
export function applyToolPolicyPipeline<TTool extends { name: string }>(params: {
|
||||
tools: TTool[];
|
||||
toolMeta: (tool: TTool) => { pluginId: string } | undefined;
|
||||
warn: (message: string) => void;
|
||||
steps: ToolPolicyPipelineStep[];
|
||||
auditLogLevel?: ToolPolicyAuditLogLevel;
|
||||
declaredToolAllowlist?: DeclaredToolAllowlistContext;
|
||||
onFilter?: (event: ToolPolicyFilterEvent) => void;
|
||||
}): AnyAgentTool[] {
|
||||
onFilter?: (event: ToolPolicyFilterEvent<TTool>) => void;
|
||||
}): TTool[] {
|
||||
const coreToolNames = new Set(
|
||||
params.tools
|
||||
.filter((tool) => !params.toolMeta(tool))
|
||||
|
||||
@@ -468,6 +468,7 @@ export function createGatewayWorkerPlacementRuntime(params: GatewayWorkerPlaceme
|
||||
environments: params.environments,
|
||||
placements: params.placements,
|
||||
admitNewPlacements: params.admitNewPlacements,
|
||||
resolveWorkspacePath,
|
||||
redispatchReclaimed: createReclaimedPlacementRedispatch({
|
||||
environments: params.environments,
|
||||
dispatch: dispatchService.dispatch,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getAgentEventLifecycleGeneration,
|
||||
getAgentRunContext,
|
||||
onAgentRuntimeEvent,
|
||||
releaseAgentRunContext,
|
||||
sweepStaleRunContexts,
|
||||
type AgentEventRuntimePayload as Event,
|
||||
} from "../../infra/agent-events.js";
|
||||
@@ -316,6 +317,27 @@ describe("worker live events", () => {
|
||||
expect(deltas()).toEqual(["first", "second", "new", "current"]);
|
||||
});
|
||||
|
||||
it("retires completed process fences when a new turn reuses its durable run id", () => {
|
||||
ack(live(1, lifecycle({ phase: "start", startedAt: 100 })));
|
||||
ack(live(2, lifecycle({ phase: "end", startedAt: 100, endedAt: 200 })));
|
||||
const credentialHash = ["next", "process", "credential"].join("-");
|
||||
|
||||
expect(
|
||||
rx.rotateCredential({
|
||||
credentialHash,
|
||||
environmentId: ID.environmentId,
|
||||
newProcessTurn: true,
|
||||
previousCredentialHash: ID.credentialHash,
|
||||
runEpoch: EPOCH,
|
||||
sessionId: SID,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const nextProcess = { ...ID, credentialHash };
|
||||
ack(live(3, lifecycle({ phase: "start", startedAt: 300 })), 3, nextProcess);
|
||||
expect(events.map((event) => event.data.phase)).toEqual(["start", "end", "start"]);
|
||||
});
|
||||
|
||||
it("ACKs before buffered failure", () => {
|
||||
const first = msg(1, "first", 0, "run-prefix");
|
||||
const second = msg(2, "second", 0, "run-buffered");
|
||||
@@ -579,6 +601,51 @@ describe("worker live events", () => {
|
||||
expect(events[0]?.controlUiVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("shares a compatible non-exclusive Gateway run owner", () => {
|
||||
const lifecycleGeneration = getAgentEventLifecycleGeneration();
|
||||
const gatewayClaim = claimAgentRunContext(
|
||||
RUN,
|
||||
{
|
||||
sessionId: LOCAL.sessionId,
|
||||
sessionKey: LOCAL.sessionKey,
|
||||
isControlUiVisible: false,
|
||||
lifecycleGeneration,
|
||||
},
|
||||
{ ownsContext: true, trackOwner: true },
|
||||
);
|
||||
expect(gatewayClaim).toBeDefined();
|
||||
|
||||
ack(live(1, lifecycle({ phase: "start", startedAt: 100 })));
|
||||
|
||||
expect(getAgentRunContext(RUN)).toMatchObject({
|
||||
...LOCAL,
|
||||
isControlUiVisible: false,
|
||||
lifecycleGeneration,
|
||||
projectSessionActive: true,
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.controlUiVisible).toBe(false);
|
||||
|
||||
rx.clear();
|
||||
expect(getAgentRunContext(RUN)).toBeDefined();
|
||||
releaseAgentRunContext(RUN, gatewayClaim);
|
||||
});
|
||||
|
||||
it("rejects a compatible context held by an exclusive Gateway owner", () => {
|
||||
const lifecycleGeneration = getAgentEventLifecycleGeneration();
|
||||
const gatewayClaim = claimAgentRunContext(
|
||||
RUN,
|
||||
{ ...LOCAL, lifecycleGeneration },
|
||||
{ exclusive: true, ownsContext: true, trackOwner: true },
|
||||
);
|
||||
expect(gatewayClaim).toBeDefined();
|
||||
|
||||
fail(msg(1, "blocked"), "invalid-event");
|
||||
expect(events).toEqual([]);
|
||||
|
||||
releaseAgentRunContext(RUN, gatewayClaim);
|
||||
});
|
||||
|
||||
it("rejects pre-registered gateway run contexts with mismatched identity", () => {
|
||||
const lifecycleGeneration = getAgentEventLifecycleGeneration();
|
||||
const mismatches: Array<{
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
claimAgentRunContext,
|
||||
emitAgentEventIfCurrent,
|
||||
emitAgentEventForOwner,
|
||||
getAgentEventLifecycleGeneration,
|
||||
getAgentRunContext,
|
||||
@@ -43,6 +44,7 @@ type PendingLiveEvent = {
|
||||
type OwnedLiveRun = {
|
||||
claimId: string;
|
||||
controlUiVisible: boolean;
|
||||
emissionMode: "exclusive" | "shared";
|
||||
lifecycleGeneration: string;
|
||||
trajectoryRecorder: WorkerLiveTrajectoryRecorder;
|
||||
};
|
||||
@@ -60,6 +62,7 @@ type BoundLiveSession = WorkerLiveSessionBinding & { target: LiveEventTarget };
|
||||
type WorkerLiveCredentialRotation = Readonly<{
|
||||
credentialHash: string;
|
||||
environmentId: string;
|
||||
newProcessTurn?: boolean;
|
||||
previousCredentialHash: string;
|
||||
runEpoch: number;
|
||||
sessionId: string;
|
||||
@@ -233,6 +236,18 @@ export function createWorkerLiveEventReceiver(options: WorkerLiveEventReceiverOp
|
||||
window.environmentId === rotation.environmentId &&
|
||||
window.runEpoch === rotation.runEpoch
|
||||
) {
|
||||
if (rotation.newProcessTurn === true) {
|
||||
// A per-turn credential is an unforgeable process boundary. Retire only
|
||||
// the prior process's transient run claims/fences while preserving the
|
||||
// durable ACK cursor; cron may intentionally reuse its durable run id.
|
||||
for (const [runId, owned] of window.activeRuns) {
|
||||
releaseAgentRunContext(runId, owned.claimId);
|
||||
}
|
||||
window.activeRuns.clear();
|
||||
window.pending.clear();
|
||||
window.pendingBytes = 0;
|
||||
window.terminalRuns.clear();
|
||||
}
|
||||
window.credentialHash = rotation.credentialHash;
|
||||
return true;
|
||||
}
|
||||
@@ -552,12 +567,14 @@ export function createWorkerLiveEventReceiver(options: WorkerLiveEventReceiverOp
|
||||
existingContext &&
|
||||
(existingContext.sessionId !== window.sessionId ||
|
||||
existingContext.sessionKey !== window.target.sessionKey ||
|
||||
existingContext.agentId !== window.target.agentId ||
|
||||
(existingContext.agentId !== undefined &&
|
||||
existingContext.agentId !== window.target.agentId) ||
|
||||
existingContext.lifecycleGeneration !== lifecycleGeneration)
|
||||
) {
|
||||
return invalidEvent();
|
||||
}
|
||||
const claimId = claimAgentRunContext(
|
||||
let emissionMode: OwnedLiveRun["emissionMode"] = "exclusive";
|
||||
let claimId = claimAgentRunContext(
|
||||
runId,
|
||||
{
|
||||
...(window.target.agentId ? { agentId: window.target.agentId } : {}),
|
||||
@@ -579,12 +596,40 @@ export function createWorkerLiveEventReceiver(options: WorkerLiveEventReceiverOp
|
||||
trackOwner: true,
|
||||
},
|
||||
);
|
||||
if (!claimId && existingContext) {
|
||||
// Cron and other Gateway-owned handoffs retain a non-exclusive claim while the
|
||||
// assigned worker runs. Share only that corroborated identity; exclusive owners
|
||||
// still reject this claim and prevent a foreign execution from joining the run.
|
||||
claimId = claimAgentRunContext(
|
||||
runId,
|
||||
{
|
||||
...(window.target.agentId ? { agentId: window.target.agentId } : {}),
|
||||
isControlUiVisible: controlUiVisible,
|
||||
lifecycleGeneration,
|
||||
projectSessionActive: true,
|
||||
sessionId: window.sessionId,
|
||||
sessionKey: window.target.sessionKey,
|
||||
},
|
||||
{
|
||||
exclusive: false,
|
||||
onClearRequested: (clearedClaimId) => {
|
||||
if (window.activeRuns.get(runId)?.claimId === clearedClaimId) {
|
||||
fenceReleasedRun(window, runId);
|
||||
}
|
||||
},
|
||||
ownsContext: false,
|
||||
trackOwner: true,
|
||||
},
|
||||
);
|
||||
emissionMode = "shared";
|
||||
}
|
||||
if (!claimId) {
|
||||
return invalidEvent();
|
||||
}
|
||||
const claimed = {
|
||||
claimId,
|
||||
controlUiVisible,
|
||||
emissionMode,
|
||||
lifecycleGeneration,
|
||||
trajectoryRecorder: createWorkerLiveTrajectoryRecorder({ runId, target: window.target }),
|
||||
};
|
||||
@@ -607,14 +652,21 @@ export function createWorkerLiveEventReceiver(options: WorkerLiveEventReceiverOp
|
||||
// Fence first so terminal delivery cannot reopen the run ID.
|
||||
window.terminalRuns.set(request.runId, request.seq);
|
||||
}
|
||||
emitAgentEventForOwner(
|
||||
{
|
||||
runId: request.runId,
|
||||
stream: request.event.kind,
|
||||
data: prepareWorkerLiveEventData(request.event),
|
||||
},
|
||||
owned.claimId,
|
||||
);
|
||||
const event = {
|
||||
runId: request.runId,
|
||||
stream: request.event.kind,
|
||||
data: prepareWorkerLiveEventData(request.event),
|
||||
};
|
||||
if (owned.emissionMode === "shared") {
|
||||
if (!emitAgentEventIfCurrent(event)) {
|
||||
if (definitiveTerminal) {
|
||||
window.terminalRuns.delete(request.runId);
|
||||
}
|
||||
return invalidEvent();
|
||||
}
|
||||
} else {
|
||||
emitAgentEventForOwner(event, owned.claimId);
|
||||
}
|
||||
recordWorkerLiveTrajectoryEvent(owned.trajectoryRecorder, request.event);
|
||||
// Gateway handler owns cleanup so detach can revoke deferred terminal delivery.
|
||||
return undefined;
|
||||
|
||||
@@ -1528,6 +1528,7 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService
|
||||
options.liveEvents?.rotateCredential({
|
||||
credentialHash: minted.credentialHash,
|
||||
environmentId: binding.environmentId,
|
||||
newProcessTurn: true,
|
||||
previousCredentialHash: previous.credentialHash,
|
||||
runEpoch: binding.ownerEpoch,
|
||||
sessionId: binding.sessionId,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionPlacementTurnParams } from "../../agents/session-placement-admission.js";
|
||||
import { resolveWorkerToolAuthority } from "./worker-tool-authority.js";
|
||||
|
||||
function turn(overrides: Partial<SessionPlacementTurnParams> = {}): SessionPlacementTurnParams {
|
||||
return {
|
||||
sessionId: "session-worker-authority",
|
||||
sessionKey: "agent:main:cron:job:run:session",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
prompt: "run",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-worker-authority",
|
||||
provider: "openai",
|
||||
model: "gpt-test",
|
||||
agentId: "main",
|
||||
...overrides,
|
||||
} as SessionPlacementTurnParams;
|
||||
}
|
||||
|
||||
function authority(overrides: Partial<SessionPlacementTurnParams> = {}) {
|
||||
return resolveWorkerToolAuthority({
|
||||
modelRef: { provider: "openai", model: "gpt-test" },
|
||||
turn: turn(overrides),
|
||||
}).allowedToolNames;
|
||||
}
|
||||
|
||||
describe("resolveWorkerToolAuthority", () => {
|
||||
it("keeps the deterministic complete worker surface when no policy narrows it", () => {
|
||||
expect(authority()).toEqual(["read", "write", "edit", "apply_patch", "exec", "process"]);
|
||||
});
|
||||
|
||||
it("projects runtime caps with canonical write-to-apply_patch semantics", () => {
|
||||
expect(authority({ toolsAllow: ["write"] })).toEqual(["write", "apply_patch"]);
|
||||
expect(authority({ toolsAllow: [] })).toEqual([]);
|
||||
expect(authority({ toolsAllow: ["web_search"] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses scheduled owner group policy without reapplying fresh sender overlays", () => {
|
||||
const config = {
|
||||
tools: {
|
||||
deny: ["exec"],
|
||||
toolsBySender: { "*": { deny: ["write", "apply_patch"] } },
|
||||
},
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groups: {
|
||||
team: {
|
||||
tools: { allow: ["read", "write", "exec"] },
|
||||
toolsBySender: { "*": { deny: ["write", "apply_patch"] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as SessionPlacementTurnParams["config"];
|
||||
|
||||
expect(
|
||||
authority({
|
||||
config,
|
||||
messageProvider: "whatsapp",
|
||||
senderId: "guest",
|
||||
toolsAllow: ["read", "write", "exec"],
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:main:whatsapp:group:team" },
|
||||
}),
|
||||
).toEqual(["read", "write", "apply_patch"]);
|
||||
expect(
|
||||
authority({
|
||||
config,
|
||||
messageProvider: "whatsapp",
|
||||
senderId: "guest",
|
||||
toolsAllow: ["read", "write", "exec"],
|
||||
}),
|
||||
).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it("re-resolves current owner-group restrictions for every scheduled turn", () => {
|
||||
expect(
|
||||
authority({
|
||||
config: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groups: { team: { tools: { deny: ["write", "apply_patch"] } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
messageProvider: "whatsapp",
|
||||
toolsAllow: ["write"],
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:main:whatsapp:group:team" },
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies sandbox tool policy when the session is configured for sandboxing", () => {
|
||||
expect(
|
||||
authority({
|
||||
sessionKey: "agent:main:worker-sandboxed",
|
||||
config: {
|
||||
agents: { defaults: { sandbox: { mode: "all" } } },
|
||||
tools: { sandbox: { tools: { allow: ["read"] } } },
|
||||
},
|
||||
}),
|
||||
).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it.each([{ disableTools: true }, { modelRun: true }, { promptMode: "none" as const }])(
|
||||
"exposes no tools for non-tool run mode %#",
|
||||
(overrides) => {
|
||||
expect(authority(overrides)).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { resolveConversationCapabilityProfile } from "../../agents/conversation-capability-profile.js";
|
||||
import { projectConversationToolNames } from "../../agents/conversation-tool-policy-pipeline.js";
|
||||
import { applyEmbeddedAttemptToolsAllow } from "../../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js";
|
||||
import type { SessionPlacementTurnParams } from "../../agents/session-placement-admission.js";
|
||||
import { logWarn } from "../../logger.js";
|
||||
import {
|
||||
WORKER_LOCAL_TOOL_NAMES,
|
||||
type WorkerLocalToolName,
|
||||
type WorkerToolAuthority,
|
||||
} from "../../worker/tool-authority.js";
|
||||
|
||||
function resolveWorkerCapabilityProfile(params: {
|
||||
modelRef: { provider: string; model: string };
|
||||
turn: SessionPlacementTurnParams;
|
||||
}) {
|
||||
const turn = params.turn;
|
||||
const sandboxSessionKey =
|
||||
turn.sandboxSessionKey?.trim() || turn.sessionKey?.trim() || turn.sessionId;
|
||||
const sandbox = resolveSandboxRuntimeStatus({
|
||||
cfg: turn.config,
|
||||
sessionKey: sandboxSessionKey,
|
||||
agentId: turn.agentId,
|
||||
});
|
||||
return resolveConversationCapabilityProfile({
|
||||
config: turn.config,
|
||||
sessionKey: sandboxSessionKey,
|
||||
runSessionKey:
|
||||
turn.sessionKey && turn.sessionKey !== sandboxSessionKey ? turn.sessionKey : undefined,
|
||||
sessionId: turn.sessionId,
|
||||
runId: turn.runId,
|
||||
agentId: turn.agentId,
|
||||
agentDir: turn.agentDir,
|
||||
agentAccountId: turn.agentAccountId,
|
||||
messageProvider: turn.messageProvider,
|
||||
messageChannel: turn.messageChannel,
|
||||
chatType: turn.chatType,
|
||||
messageTo: turn.messageTo,
|
||||
messageThreadId: turn.messageThreadId,
|
||||
currentChannelId: turn.currentChannelId,
|
||||
currentMessagingTarget: turn.currentMessagingTarget,
|
||||
currentThreadTs: turn.currentThreadTs,
|
||||
currentMessageId: turn.currentMessageId,
|
||||
groupId: turn.groupId,
|
||||
groupChannel: turn.groupChannel,
|
||||
groupSpace: turn.groupSpace,
|
||||
memberRoleIds: turn.memberRoleIds,
|
||||
spawnedBy: turn.spawnedBy,
|
||||
senderId: turn.senderId,
|
||||
senderName: turn.senderName,
|
||||
senderUsername: turn.senderUsername,
|
||||
senderE164: turn.senderE164,
|
||||
senderIsOwner: turn.senderIsOwner,
|
||||
modelProvider: params.modelRef.provider,
|
||||
modelId: params.modelRef.model,
|
||||
workspaceDir: turn.workspaceDir,
|
||||
cwd: turn.cwd,
|
||||
isCanonicalWorkspace: turn.isCanonicalWorkspace,
|
||||
promptMode: turn.promptMode,
|
||||
skillsSnapshot: turn.skillsSnapshot,
|
||||
sandboxToolPolicy: sandbox.sandboxed ? sandbox.toolPolicy : undefined,
|
||||
runtimeToolAllowlist: turn.toolsAllow,
|
||||
inheritRuntimeToolAllowlist: true,
|
||||
runtimePluginToolGrant: turn.runtimePluginToolGrant,
|
||||
inputProvenance: turn.inputProvenance,
|
||||
trustedInternalHandoff: turn.trustedInternalHandoff,
|
||||
scheduledToolPolicy: turn.scheduledToolPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves the final fixed worker surface at the trusted Gateway handoff boundary. */
|
||||
export function resolveWorkerToolAuthority(params: {
|
||||
modelRef: { provider: string; model: string };
|
||||
turn: SessionPlacementTurnParams;
|
||||
}): WorkerToolAuthority {
|
||||
const turn = params.turn;
|
||||
if (turn.disableTools === true || turn.modelRun === true || turn.promptMode === "none") {
|
||||
return { allowedToolNames: [] };
|
||||
}
|
||||
const runtimeCappedTools = applyEmbeddedAttemptToolsAllow(
|
||||
WORKER_LOCAL_TOOL_NAMES.map((name) => ({ name })),
|
||||
turn.toolsAllow,
|
||||
);
|
||||
const projected: WorkerLocalToolName[] = projectConversationToolNames({
|
||||
capabilityProfile: resolveWorkerCapabilityProfile(params),
|
||||
toolNames: runtimeCappedTools.map((tool) => tool.name),
|
||||
warn: logWarn,
|
||||
});
|
||||
return { allowedToolNames: projected };
|
||||
}
|
||||
@@ -30,9 +30,9 @@ import {
|
||||
} from "./placement-store.js";
|
||||
import { createWorkerSessionPlacementGate } from "./placement-worker-gate.js";
|
||||
import type { WorkerTunnelHandle } from "./tunnel-contract.js";
|
||||
import { createWorkerSessionTurnPlacementProvider } from "./worker-turn-launcher.js";
|
||||
import { createWorkerSessionTurnPlacementProvider as createRawWorkerSessionTurnPlacementProvider } from "./worker-turn-launcher.js";
|
||||
|
||||
type WorkerTurnLauncherOptions = Parameters<typeof createWorkerSessionTurnPlacementProvider>[0];
|
||||
type WorkerTurnLauncherOptions = Parameters<typeof createRawWorkerSessionTurnPlacementProvider>[0];
|
||||
type WorkerTurnEnvironmentService = WorkerTurnLauncherOptions["environments"];
|
||||
|
||||
const SESSION_ID = "session-worker-turn";
|
||||
@@ -74,6 +74,16 @@ describe("worker turn launcher", () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createWorkerSessionTurnPlacementProvider(
|
||||
options: Omit<WorkerTurnLauncherOptions, "resolveWorkspacePath"> &
|
||||
Partial<Pick<WorkerTurnLauncherOptions, "resolveWorkspacePath">>,
|
||||
) {
|
||||
return createRawWorkerSessionTurnPlacementProvider({
|
||||
resolveWorkspacePath: async () => root,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function seedActivePlacement(): void {
|
||||
let placement = placements.startDispatch({
|
||||
sessionId: SESSION_ID,
|
||||
@@ -594,7 +604,12 @@ describe("worker turn launcher", () => {
|
||||
stopTunnel: vi.fn(async () => {}),
|
||||
destroy: vi.fn(async () => attachedEnvironment()),
|
||||
};
|
||||
const provider = createWorkerSessionTurnPlacementProvider({ environments, placements });
|
||||
const resolveWorkspacePath = vi.fn(async () => root);
|
||||
const provider = createWorkerSessionTurnPlacementProvider({
|
||||
environments,
|
||||
placements,
|
||||
resolveWorkspacePath,
|
||||
});
|
||||
const runLocal = vi.fn(async () => ({ meta: { durationMs: 1 } }));
|
||||
const onAgentEvent = vi.fn(() => {
|
||||
throw new Error("supplemental event failed");
|
||||
@@ -607,11 +622,25 @@ describe("worker turn launcher", () => {
|
||||
agentId: "main",
|
||||
runId: "run-worker-turn",
|
||||
},
|
||||
{ ...turn(), transcriptPrompt: "Canonical transcript request", onAgentEvent },
|
||||
{
|
||||
...turn(),
|
||||
workspaceDir: path.join(root, "stale-caller-workspace"),
|
||||
transcriptPrompt: "Canonical transcript request",
|
||||
onAgentEvent,
|
||||
},
|
||||
runLocal,
|
||||
);
|
||||
|
||||
expect(runLocal).not.toHaveBeenCalled();
|
||||
expect(resolveWorkspacePath).toHaveBeenCalledWith({
|
||||
sessionId: SESSION_ID,
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
runId: "run-worker-turn",
|
||||
});
|
||||
expect(tunnel.reconcileWorkspace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ localPath: root }),
|
||||
);
|
||||
const conflictSummary =
|
||||
"Cloud result applied with 1 conflict(s); kept local versions: src/local.ts. Cloud versions staged at refs/openclaw/worker-results/";
|
||||
expect(result.payloads).toEqual([
|
||||
@@ -639,6 +668,15 @@ describe("worker turn launcher", () => {
|
||||
).toBe(true);
|
||||
expect(descriptor?.assignment.prompt).toBe("Inspect this workspace");
|
||||
expect(descriptor?.assignment.suppressPromptTranscript).toBe(true);
|
||||
expect(descriptor?.version).toBe(2);
|
||||
expect(descriptor?.assignment.toolAuthority.allowedToolNames).toEqual([
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"apply_patch",
|
||||
"exec",
|
||||
"process",
|
||||
]);
|
||||
expect(descriptor?.assignment.initialMessages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
WorkerSessionTurnClaim,
|
||||
} from "./placement-store.js";
|
||||
import type { WorkerEnvironmentService } from "./service.js";
|
||||
import { resolveWorkerToolAuthority } from "./worker-tool-authority.js";
|
||||
import {
|
||||
claimWorkerTurn,
|
||||
latestDurableWorkspaceConflict,
|
||||
@@ -70,6 +71,7 @@ type WorkerTurnLauncherOptions = {
|
||||
admitNewPlacements?: boolean;
|
||||
environments: WorkerTurnEnvironmentService;
|
||||
placements: WorkerSessionPlacementStore;
|
||||
resolveWorkspacePath: (claim: LocalTurnPlacementClaim) => Promise<string>;
|
||||
workspaceOperations?: WorkerWorkspaceOperationCoordinator;
|
||||
redispatchReclaimed?: (placement: ReclaimedWorkerPlacement) => Promise<ActiveWorkerPlacement>;
|
||||
};
|
||||
@@ -172,6 +174,7 @@ async function executeWorkerTurn(params: {
|
||||
workspaceOperations: WorkerWorkspaceOperationCoordinator;
|
||||
turn: SessionPlacementTurnParams;
|
||||
turnClaim: WorkerSessionTurnClaim;
|
||||
localWorkspaceDir: string;
|
||||
}) {
|
||||
const { placement, turn } = params;
|
||||
const modelRef = assertSupportedTurn(turn);
|
||||
@@ -216,7 +219,10 @@ async function executeWorkerTurn(params: {
|
||||
}
|
||||
const pending = journal.load();
|
||||
if (pending) {
|
||||
await recoverWorkerWorkspaceReconciliation({ root: turn.workspaceDir, journal: pending });
|
||||
await recoverWorkerWorkspaceReconciliation({
|
||||
root: params.localWorkspaceDir,
|
||||
journal: pending,
|
||||
});
|
||||
journal.abort();
|
||||
}
|
||||
});
|
||||
@@ -244,7 +250,7 @@ async function executeWorkerTurn(params: {
|
||||
let baseLeafId = manager.getLeafId();
|
||||
if (!userMessageAlreadyPersisted) {
|
||||
const persisted = turn.userTurnTranscriptRecorder
|
||||
? await turn.userTurnTranscriptRecorder.persistApproved({ cwd: turn.workspaceDir })
|
||||
? await turn.userTurnTranscriptRecorder.persistApproved({ cwd: params.localWorkspaceDir })
|
||||
: undefined;
|
||||
if (persisted) {
|
||||
baseLeafId = persisted.messageId;
|
||||
@@ -285,10 +291,11 @@ async function executeWorkerTurn(params: {
|
||||
timeoutMs: turn.timeoutMs,
|
||||
});
|
||||
const reasoning = mapThinkingLevelForProvider(turn.thinkLevel);
|
||||
const toolAuthority = resolveWorkerToolAuthority({ modelRef, turn });
|
||||
const descriptor = fitLaunchDescriptor(
|
||||
(windowedMessages) =>
|
||||
parseWorkerLaunchDescriptor({
|
||||
version: 1,
|
||||
version: 2,
|
||||
socketPath: tunnel.remoteSocketPath,
|
||||
admission: {
|
||||
environmentId: placement.environmentId,
|
||||
@@ -316,6 +323,7 @@ async function executeWorkerTurn(params: {
|
||||
ackedSeq: placement.lastLiveEventAckCursor ?? 0,
|
||||
nextSeq: (placement.lastLiveEventAckCursor ?? 0) + 1,
|
||||
},
|
||||
toolAuthority,
|
||||
},
|
||||
}),
|
||||
initialMessages,
|
||||
@@ -426,7 +434,7 @@ async function executeWorkerTurn(params: {
|
||||
try {
|
||||
const stagedResultRef = workerWorkspaceResultRef(params.turnClaim.claimId);
|
||||
const reconciliation = await tunnel.reconcileWorkspace({
|
||||
localPath: turn.workspaceDir,
|
||||
localPath: params.localWorkspaceDir,
|
||||
remoteWorkspaceDir: currentPlacement.remoteWorkspaceDir,
|
||||
baseManifestRef: currentPlacement.workspaceBaseManifestRef,
|
||||
journal,
|
||||
@@ -457,7 +465,7 @@ async function executeWorkerTurn(params: {
|
||||
conflictPaths: applied?.conflictPaths ?? [],
|
||||
priorConflict: priorWorkspaceConflict,
|
||||
stagedResultRef: recordedStagedResultRef,
|
||||
root: turn.workspaceDir,
|
||||
root: params.localWorkspaceDir,
|
||||
report: async (report) => {
|
||||
if ("cleared" in report) {
|
||||
SessionManager.open(turn.sessionFile).appendCustomMessageEntry(
|
||||
@@ -490,7 +498,7 @@ async function executeWorkerTurn(params: {
|
||||
await settleStagedWorkspaceResult({
|
||||
placements: params.placements,
|
||||
turnClaim: params.turnClaim,
|
||||
root: turn.workspaceDir,
|
||||
root: params.localWorkspaceDir,
|
||||
stagedResultRef: recordedStagedResultRef,
|
||||
conflictRetained: finalized.conflictRetained,
|
||||
reclaim: false,
|
||||
@@ -585,6 +593,9 @@ export function createWorkerSessionTurnPlacementProvider(
|
||||
}
|
||||
const identity = resolvePlacementIdentity(claim, routablePlacement);
|
||||
let placement = requireActivePlacement(routablePlacement);
|
||||
// The placement owns the managed worktree. Callers can carry a default or stale
|
||||
// workspace path, but remote results must only reconcile into that canonical root.
|
||||
const localWorkspaceDir = await options.resolveWorkspacePath(claim);
|
||||
const admitted = await claimWorkerTurn({
|
||||
placements: options.placements,
|
||||
identity,
|
||||
@@ -603,6 +614,7 @@ export function createWorkerSessionTurnPlacementProvider(
|
||||
},
|
||||
placement,
|
||||
placements: options.placements,
|
||||
localWorkspaceDir,
|
||||
workspaceOperations,
|
||||
turn,
|
||||
turnClaim,
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { SessionPlacementTurnParams } from "../../agents/session-placement-
|
||||
import { assertSupportedTurn } from "./worker-turn-payload.js";
|
||||
|
||||
describe("assertSupportedTurn", () => {
|
||||
it("rejects scheduled authority before cloud-worker handoff", () => {
|
||||
expect(() =>
|
||||
it("accepts scheduled authority for the worker launch envelope", () => {
|
||||
expect(
|
||||
assertSupportedTurn({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
@@ -14,9 +14,16 @@ describe("assertSupportedTurn", () => {
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: { "openai/gpt-5.4": { agentRuntime: { id: "openclaw" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
toolsAllow: ["write"],
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" },
|
||||
} as SessionPlacementTurnParams),
|
||||
).toThrow("Cloud worker turns do not yet preserve scheduled tool policy");
|
||||
).toEqual({ provider: "openai", model: "gpt-5.4" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,9 +176,6 @@ export function assertSupportedTurn(params: SessionPlacementTurnParams): {
|
||||
if (params.clientTools?.length) {
|
||||
throw new Error("Cloud worker turns do not support client-provided tools");
|
||||
}
|
||||
if (params.scheduledToolPolicy) {
|
||||
throw new Error("Cloud worker turns do not yet preserve scheduled tool policy");
|
||||
}
|
||||
const modelRef = resolveTurnModelRef(params);
|
||||
const explicitRuntime =
|
||||
normalizeOptionalAgentRuntimeId(params.agentHarnessId) ??
|
||||
|
||||
@@ -27,17 +27,9 @@ import {
|
||||
toAgentMessage,
|
||||
toWorkerInferenceContext,
|
||||
} from "./embedded-agent-transcript.runtime.js";
|
||||
import { WORKER_LOCAL_TOOL_NAMES, type WorkerLocalToolName } from "./tool-authority.js";
|
||||
import { toWorkerTranscriptMessage } from "./transcript-message.js";
|
||||
|
||||
const LOCAL_WORKER_TOOL_NAMES = [
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"apply_patch",
|
||||
"exec",
|
||||
"process",
|
||||
] as const;
|
||||
|
||||
function toError(value: unknown, fallback: string): Error {
|
||||
return value instanceof Error ? value : new Error(fallback, { cause: value });
|
||||
}
|
||||
@@ -78,6 +70,7 @@ type RunWorkerEmbeddedTurnParams = {
|
||||
suppressPromptTranscript?: boolean;
|
||||
systemPrompt?: string;
|
||||
inferenceOptions?: WorkerInferenceOptions;
|
||||
allowedToolNames: readonly WorkerLocalToolName[];
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
@@ -127,7 +120,9 @@ export async function runWorkerEmbeddedTurn(
|
||||
onMessagePersisted: transcriptRuntime.onMessagePersisted,
|
||||
});
|
||||
|
||||
const toolNameSet = new Set<string>(LOCAL_WORKER_TOOL_NAMES);
|
||||
const allowedToolNameSet = new Set<string>(params.allowedToolNames);
|
||||
const activeToolNames = WORKER_LOCAL_TOOL_NAMES.filter((name) => allowedToolNameSet.has(name));
|
||||
const localToolNameSet = new Set<string>(WORKER_LOCAL_TOOL_NAMES);
|
||||
const localTools = createOpenClawCodingTools({
|
||||
cwd: params.cwd,
|
||||
workspaceDir: params.cwd,
|
||||
@@ -138,7 +133,7 @@ export async function runWorkerEmbeddedTurn(
|
||||
oneShotCliRun: true,
|
||||
senderIsOwner: true,
|
||||
disableMessageTool: true,
|
||||
runtimeToolAllowlist: [...LOCAL_WORKER_TOOL_NAMES],
|
||||
runtimeToolAllowlist: [...WORKER_LOCAL_TOOL_NAMES],
|
||||
modelProvider: params.modelRef.provider,
|
||||
modelId: params.modelRef.model,
|
||||
modelApi: model.api,
|
||||
@@ -152,9 +147,9 @@ export async function runWorkerEmbeddedTurn(
|
||||
includeOpenClawTools: false,
|
||||
includePluginTools: false,
|
||||
},
|
||||
}).filter((tool) => toolNameSet.has(tool.name));
|
||||
}).filter((tool) => localToolNameSet.has(tool.name));
|
||||
const discoveredToolNames = new Set(localTools.map((tool) => tool.name));
|
||||
for (const toolName of LOCAL_WORKER_TOOL_NAMES) {
|
||||
for (const toolName of WORKER_LOCAL_TOOL_NAMES) {
|
||||
if (!discoveredToolNames.has(toolName)) {
|
||||
throw new Error(`Worker coding tool unavailable: ${toolName}`);
|
||||
}
|
||||
@@ -167,8 +162,8 @@ export async function runWorkerEmbeddedTurn(
|
||||
modelRegistry,
|
||||
model,
|
||||
thinkingLevel: "medium",
|
||||
tools: [...LOCAL_WORKER_TOOL_NAMES],
|
||||
customTools: toToolDefinitions(localTools),
|
||||
tools: [...activeToolNames],
|
||||
customTools: toToolDefinitions(localTools.filter((tool) => allowedToolNameSet.has(tool.name))),
|
||||
noTools: "all",
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
@@ -176,7 +171,7 @@ export async function runWorkerEmbeddedTurn(
|
||||
withSessionWriteLock: transcriptRuntime.withSessionWriteLock,
|
||||
});
|
||||
session.agent.sessionId = params.sessionId;
|
||||
session.setActiveToolsByName([...LOCAL_WORKER_TOOL_NAMES]);
|
||||
session.setActiveToolsByName([...activeToolNames]);
|
||||
session.agent.streamFn = (_model, context, options) =>
|
||||
params.inference.stream({
|
||||
modelRef: params.modelRef,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { buildWorkerConnectParams, parseWorkerLaunchDescriptor } from "./launch-
|
||||
|
||||
function launchDescriptor(): WorkerLaunchDescriptor {
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
socketPath: "/tmp/openclaw-worker/gateway.sock",
|
||||
admission: {
|
||||
environmentId: "environment-1",
|
||||
@@ -41,6 +41,7 @@ function launchDescriptor(): WorkerLaunchDescriptor {
|
||||
],
|
||||
transcript: { baseLeafId: "leaf-7", nextSeq: 8 },
|
||||
liveEvents: { ackedSeq: 12, nextSeq: 13 },
|
||||
toolAuthority: { allowedToolNames: ["read", "exec"] },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -97,6 +98,13 @@ describe("worker launch descriptor", () => {
|
||||
liveEvents: { ...descriptor.assignment.liveEvents, unexpected: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
...descriptor,
|
||||
assignment: {
|
||||
...descriptor.assignment,
|
||||
toolAuthority: { ...descriptor.assignment.toolAuthority, unexpected: true },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const candidate of cases) {
|
||||
@@ -106,6 +114,38 @@ describe("worker launch descriptor", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("requires a unique closed worker tool authority", () => {
|
||||
const descriptor = launchDescriptor();
|
||||
const { toolAuthority: _missing, ...assignmentWithoutAuthority } = descriptor.assignment;
|
||||
const cases: unknown[] = [
|
||||
{ ...descriptor, version: 1 },
|
||||
{ ...descriptor, assignment: assignmentWithoutAuthority },
|
||||
{
|
||||
...descriptor,
|
||||
assignment: {
|
||||
...descriptor.assignment,
|
||||
toolAuthority: { allowedToolNames: ["read", "read"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
...descriptor,
|
||||
assignment: {
|
||||
...descriptor.assignment,
|
||||
toolAuthority: { allowedToolNames: ["read", "gateway"] },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const candidate of cases) {
|
||||
expect(() => parseWorkerLaunchDescriptor(candidate)).toThrow(
|
||||
"invalid worker launch descriptor",
|
||||
);
|
||||
}
|
||||
|
||||
descriptor.assignment.toolAuthority.allowedToolNames = [];
|
||||
expect(parseWorkerLaunchDescriptor(structuredClone(descriptor))).toEqual(descriptor);
|
||||
});
|
||||
|
||||
it("rejects non-absolute paths, unattached sessions, and discontinuous event sequences", () => {
|
||||
const descriptor = launchDescriptor();
|
||||
const cases: unknown[] = [
|
||||
|
||||
@@ -23,9 +23,10 @@ import {
|
||||
WorkerInferenceOptionsSchema,
|
||||
} from "../../packages/gateway-protocol/src/schema/worker-inference.js";
|
||||
import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/version.js";
|
||||
import { isWorkerLocalToolName, type WorkerToolAuthority } from "./tool-authority.js";
|
||||
import { isWorkerTranscriptMessageFrameSafe } from "./transcript-message.js";
|
||||
|
||||
const LAUNCH_VERSION = 1;
|
||||
const LAUNCH_VERSION = 2;
|
||||
|
||||
type WorkerLaunchAssignment = {
|
||||
runId: string;
|
||||
@@ -45,6 +46,7 @@ type WorkerLaunchAssignment = {
|
||||
ackedSeq: number;
|
||||
nextSeq: number;
|
||||
};
|
||||
toolAuthority: WorkerToolAuthority;
|
||||
};
|
||||
|
||||
type WorkerLaunchAdmission = Omit<WorkerConnectParams["admission"], "runId"> & {
|
||||
@@ -52,7 +54,7 @@ type WorkerLaunchAdmission = Omit<WorkerConnectParams["admission"], "runId"> & {
|
||||
};
|
||||
|
||||
export type WorkerLaunchDescriptor = {
|
||||
version: 1;
|
||||
version: 2;
|
||||
socketPath: string;
|
||||
admission: WorkerLaunchAdmission;
|
||||
assignment: WorkerLaunchAssignment;
|
||||
@@ -86,6 +88,19 @@ function isInferenceOptions(value: unknown): value is WorkerInferenceOptions {
|
||||
return Value.Check(WorkerInferenceOptionsSchema, value);
|
||||
}
|
||||
|
||||
function parseToolAuthority(value: unknown): WorkerToolAuthority | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasExactKeys(value, ["allowedToolNames"]) ||
|
||||
!Array.isArray(value.allowedToolNames) ||
|
||||
!value.allowedToolNames.every(isWorkerLocalToolName) ||
|
||||
new Set(value.allowedToolNames).size !== value.allowedToolNames.length
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { allowedToolNames: [...value.allowedToolNames] };
|
||||
}
|
||||
|
||||
function parseAssignment(value: unknown): WorkerLaunchAssignment | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
@@ -102,6 +117,7 @@ function parseAssignment(value: unknown): WorkerLaunchAssignment | undefined {
|
||||
"initialMessages",
|
||||
"transcript",
|
||||
"liveEvents",
|
||||
"toolAuthority",
|
||||
],
|
||||
["systemPrompt"],
|
||||
)
|
||||
@@ -122,6 +138,10 @@ function parseAssignment(value: unknown): WorkerLaunchAssignment | undefined {
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const toolAuthority = parseToolAuthority(value.toolAuthority);
|
||||
if (!toolAuthority) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!Value.Check(WorkerInferenceModelRefSchema, value.modelRef) ||
|
||||
!isInferenceOptions(value.inferenceOptions)
|
||||
@@ -145,7 +165,7 @@ function parseAssignment(value: unknown): WorkerLaunchAssignment | undefined {
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return value as WorkerLaunchAssignment;
|
||||
return { ...value, toolAuthority } as WorkerLaunchAssignment;
|
||||
}
|
||||
|
||||
export function buildWorkerConnectParams(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export const WORKER_LOCAL_TOOL_NAMES = [
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"apply_patch",
|
||||
"exec",
|
||||
"process",
|
||||
] as const;
|
||||
|
||||
export type WorkerLocalToolName = (typeof WORKER_LOCAL_TOOL_NAMES)[number];
|
||||
|
||||
const WORKER_LOCAL_TOOL_NAME_SET = new Set<string>(WORKER_LOCAL_TOOL_NAMES);
|
||||
|
||||
export function isWorkerLocalToolName(value: unknown): value is WorkerLocalToolName {
|
||||
return typeof value === "string" && WORKER_LOCAL_TOOL_NAME_SET.has(value);
|
||||
}
|
||||
|
||||
export type WorkerToolAuthority = {
|
||||
allowedToolNames: WorkerLocalToolName[];
|
||||
};
|
||||
@@ -332,7 +332,7 @@ class ComposedGatewayHarness {
|
||||
const epoch = params.epoch ?? this.epoch;
|
||||
const credential = params.admissionProof ?? CREDENTIAL;
|
||||
const descriptor: WorkerLaunchDescriptor = {
|
||||
version: 1,
|
||||
version: 2,
|
||||
socketPath: this.socketPath,
|
||||
admission: {
|
||||
environmentId: ENVIRONMENT_ID,
|
||||
@@ -356,6 +356,9 @@ class ComposedGatewayHarness {
|
||||
ackedSeq: params.initialAckedSeq ?? 0,
|
||||
nextSeq: (params.initialAckedSeq ?? 0) + 1,
|
||||
},
|
||||
toolAuthority: {
|
||||
allowedToolNames: ["read", "write", "edit", "apply_patch", "exec", "process"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const connection = createWorkerConnection({
|
||||
|
||||
@@ -706,7 +706,7 @@ class FakeWorkerGateway {
|
||||
|
||||
function descriptor(socketPath: string, workspaceDir: string): WorkerLaunchDescriptor {
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
socketPath,
|
||||
admission: {
|
||||
environmentId: "worker-environment",
|
||||
@@ -731,6 +731,9 @@ function descriptor(socketPath: string, workspaceDir: string): WorkerLaunchDescr
|
||||
initialMessages: [],
|
||||
transcript: { baseLeafId: "leaf-base", nextSeq: 3 },
|
||||
liveEvents: { ackedSeq: 0, nextSeq: 1 },
|
||||
toolAuthority: {
|
||||
allowedToolNames: ["read", "write", "edit", "apply_patch", "exec", "process"],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -808,6 +811,27 @@ describe("worker runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes exactly the Gateway-authorized worker tools", async () => {
|
||||
const { gateway, launch } = await setup();
|
||||
launch.assignment.toolAuthority.allowedToolNames = ["read", "exec"];
|
||||
|
||||
await expect(runWorkerDescriptor(launch)).resolves.toMatchObject({ status: "completed" });
|
||||
|
||||
expect(gateway.inferenceRequests[0]?.context.tools?.map((tool) => tool.name)).toEqual([
|
||||
"read",
|
||||
"exec",
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs with no tools when the Gateway authority is empty", async () => {
|
||||
const { gateway, launch } = await setup();
|
||||
launch.assignment.toolAuthority.allowedToolNames = [];
|
||||
|
||||
await expect(runWorkerDescriptor(launch)).resolves.toMatchObject({ status: "completed" });
|
||||
|
||||
expect(gateway.inferenceRequests[0]?.context.tools ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it("fail-stops a stale mid-run transcript without duplicating or rebasing the paid tail", async () => {
|
||||
const { gateway, launch } = await setup({ transcriptFailureAtRequest: 2 });
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ export async function runWorkerDescriptor(
|
||||
? {}
|
||||
: { systemPrompt: descriptor.assignment.systemPrompt }),
|
||||
inferenceOptions: descriptor.assignment.inferenceOptions,
|
||||
allowedToolNames: descriptor.assignment.toolAuthority.allowedToolNames,
|
||||
inference: { stream },
|
||||
transcript: {
|
||||
commit: async (messages) => {
|
||||
|
||||
Reference in New Issue
Block a user