fix: enforce native tool policy across harness lifecycles

This commit is contained in:
Peter Steinberger
2026-08-13 16:25:56 -07:00
parent 252bb545b4
commit b5809f5f44
38 changed files with 604 additions and 15 deletions
@@ -108,7 +108,13 @@ async function writeSupervisedTestBinding(
});
}
function startCompaction(sessionFile: string, options: { currentTokenCount?: number } = {}) {
function startCompaction(
sessionFile: string,
options: {
currentTokenCount?: number;
nativeToolSurface?: "unrestricted" | "host-isolated";
} = {},
) {
return maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
@@ -208,6 +214,42 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(details.completed).toBe(true);
});
it("does not compact a thread created with restricted native authority", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding({ nativeToolPolicyRestricted: true });
await expect(startCompaction(sessionFile)).resolves.toMatchObject({
ok: true,
compacted: false,
reason: "native compaction is unavailable for a host-isolated Codex session",
result: {
details: {
backend: "codex-app-server",
skipped: true,
reason: "native_tool_policy_restricted",
expectedThreadId: "thread-1",
},
},
});
expect(fake.request).not.toHaveBeenCalled();
});
it("does not compact an unrestricted binding during a host-isolated operation", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
await expect(
startCompaction(sessionFile, { nativeToolSurface: "host-isolated" }),
).resolves.toMatchObject({
ok: true,
compacted: false,
result: { details: { reason: "native_tool_policy_restricted" } },
});
expect(fake.request).not.toHaveBeenCalled();
});
it("compacts a warm session without displacing its independently retained sibling", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
@@ -490,6 +490,24 @@ async function compactCodexNativeThread(
recovery: "missing_thread_binding",
});
}
if (
params.nativeToolSurface === "host-isolated" ||
initialBinding.nativeToolPolicyRestricted === true ||
initialBinding.ringZeroConfigFingerprint !== undefined
) {
// Compact is a separate Codex operation without a turn-scoped environment
// override, so resuming here would silently restore ambient native tools.
return codexNativeCompactionResult(params, {
compacted: false,
reason: "native compaction is unavailable for a host-isolated Codex session",
details: {
backend: "codex-app-server",
skipped: true,
reason: "native_tool_policy_restricted",
expectedThreadId: initialBinding.threadId,
},
});
}
let binding = initialBinding;
const requestedAuthProfileId = params.authProfileId?.trim() || undefined;
let connection: ReturnType<typeof resolveCodexBindingAppServerConnection>;
@@ -250,6 +250,8 @@ const threadBindingSchema = z
configuredMcpOwnershipVersion: z.literal(1).optional().catch(undefined),
ringZeroConfigFingerprint: optionalStringSchema,
ringZeroClientInstanceId: optionalStringSchema,
/** Durable fact preventing a later unrestricted turn from widening this thread. */
nativeToolPolicyRestricted: z.literal(true).optional().catch(undefined),
nativeHookRelayGeneration: optionalNonBlankStringSchema,
appServerRuntimeFingerprint: optionalStringSchema,
pluginAppsFingerprint: optionalStringSchema,
@@ -78,6 +78,7 @@ type ThreadRequestContext = {
environmentSelectionFingerprint?: string;
hostSystemAgentActive: boolean;
ringZeroActive: boolean;
restrictedToolSurface: boolean;
restrictedToolSurfaceInheritedMcpServerNames: string[];
nativeSkillIsolation?: CodexNativeSkillIsolation;
lifecycleTiming: CodexThreadLifecycleTimingTracker;
@@ -140,6 +141,7 @@ export async function resumeExistingCodexThread(
environmentSelectionFingerprint,
hostSystemAgentActive,
ringZeroActive,
restrictedToolSurface,
restrictedToolSurfaceInheritedMcpServerNames,
nativeSkillIsolation,
lifecycleTiming,
@@ -275,6 +277,7 @@ export async function resumeExistingCodexThread(
configuredMcpOwnershipVersion: params.configuredMcpOwnershipVersion,
ringZeroConfigFingerprint,
ringZeroClientInstanceId,
nativeToolPolicyRestricted: restrictedToolSurface ? true : undefined,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
networkProxyConfigFingerprint,
nativeHookRelayGeneration:
@@ -426,6 +429,7 @@ export async function startFreshCodexThread(
environmentSelectionFingerprint,
hostSystemAgentActive,
ringZeroActive,
restrictedToolSurface,
restrictedToolSurfaceInheritedMcpServerNames,
nativeSkillIsolation,
lifecycleTiming,
@@ -589,6 +593,7 @@ export async function startFreshCodexThread(
configuredMcpOwnershipVersion: params.configuredMcpOwnershipVersion,
ringZeroConfigFingerprint,
ringZeroClientInstanceId,
nativeToolPolicyRestricted: restrictedToolSurface ? true : undefined,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
networkProxyConfigFingerprint,
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
@@ -241,6 +241,13 @@ export async function startOrResumeThread(
}
binding = undefined;
};
if (
binding?.threadId &&
!restrictedToolSurface &&
binding.nativeToolPolicyRestricted === true
) {
await clearCurrentBinding("rotating a host-policy-restricted thread binding");
}
if (
binding?.threadId &&
binding.nativeSkillIsolationFingerprint !== nativeSkillIsolationFingerprint
@@ -652,6 +659,7 @@ export async function startOrResumeThread(
environmentSelectionFingerprint,
hostSystemAgentActive,
ringZeroActive,
restrictedToolSurface,
restrictedToolSurfaceInheritedMcpServerNames,
nativeSkillIsolation,
lifecycleTiming,
@@ -687,6 +695,7 @@ export async function startOrResumeThread(
environmentSelectionFingerprint,
hostSystemAgentActive,
ringZeroActive,
restrictedToolSurface,
restrictedToolSurfaceInheritedMcpServerNames,
nativeSkillIsolation,
lifecycleTiming,
@@ -1276,6 +1276,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
dynamicTools: [messageTool],
config: {
"features.apps": true,
"features.chronicle": true,
"features.current_time_reminder": true,
"features.deferred_executor": true,
"features.hooks": true,
@@ -1283,8 +1284,12 @@ describe("Codex app-server thread lifecycle bindings", () => {
"features.multi_agent": true,
"features.multi_agent_v2": true,
"features.plugins": true,
"features.skill_search": true,
"features.shell_tool": true,
"features.standalone_web_search": true,
"features.token_budget": true,
"features.unified_exec": true,
"features.view_image": true,
"orchestrator.mcp.enabled": true,
"tools.experimental_request_user_input.enabled": true,
"tools.update_plan.enabled": true,
@@ -1424,6 +1429,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(request.config).toMatchObject({
"features.apps": false,
"features.chronicle": false,
"features.current_time_reminder": false,
"features.deferred_executor": false,
"features.hooks": false,
@@ -1432,10 +1438,16 @@ describe("Codex app-server thread lifecycle bindings", () => {
"features.multi_agent": false,
"features.multi_agent_v2": false,
"features.plugins": false,
"features.skill_search": false,
"features.shell_tool": false,
"features.standalone_web_search": false,
"features.token_budget": false,
"features.unified_exec": false,
"features.view_image": false,
"orchestrator.mcp.enabled": false,
"orchestrator.skills.enabled": false,
"skills.bundled.enabled": false,
"skills.include_instructions": false,
"tools.experimental_request_user_input.enabled": false,
"tools.update_plan.enabled": false,
mcp_servers: { inherited: { enabled: false } },
@@ -1689,6 +1701,38 @@ describe("Codex app-server thread lifecycle bindings", () => {
]);
});
it.each(["shell_tool", "unified_exec", "view_image", "skill_search", "codex_hooks"])(
"fails closed when requirements pin native registry %s on",
async (feature) => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.toolsAllow = ["openclaw"];
const request = vi.fn(async (method: string) => {
if (method === "config/read") {
return { config: {}, layers: [] };
}
if (method === "configRequirements/read") {
return { requirements: { featureRequirements: { [feature]: true } } };
}
throw new Error(`unexpected method: ${method}`);
});
await expect(
startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("openclaw")],
appServer: createThreadLifecycleAppServerOptions(),
nativeCodeModeEnabled: false,
userMcpServersEnabled: false,
hostSystemAgentActive: true,
}),
).rejects.toThrow(`cannot override required feature ${feature}`);
},
);
it.each([
{ name: "a newly raced server", attestation: { data: [{ name: "raced" }] } },
{ name: "a malformed inventory", attestation: { data: "invalid" } },
@@ -66,18 +66,24 @@ const CODEX_DELEGATION_DISABLED_THREAD_CONFIG: JsonObject = {
const CODEX_RING_ZERO_THREAD_CONFIG: JsonObject = {
...CODEX_DELEGATION_DISABLED_THREAD_CONFIG,
"features.apps": false,
"features.chronicle": false,
"features.current_time_reminder": false,
"features.deferred_executor": false,
"features.enable_fanout": false,
"features.goals": false,
"features.hooks": false,
"features.image_generation": false,
"features.memories": false,
"features.plugins": false,
"features.skill_search": false,
"features.shell_tool": false,
"features.standalone_web_search": false,
"features.token_budget": false,
"features.unified_exec": false,
"features.view_image": false,
"orchestrator.mcp.enabled": false,
"orchestrator.skills.enabled": false,
"skills.bundled.enabled": false,
"skills.include_instructions": false,
"tools.experimental_request_user_input.enabled": false,
"tools.update_plan.enabled": false,
hooks: {
@@ -99,11 +105,11 @@ const CODEX_RING_ZERO_THREAD_CONFIG: JsonObject = {
const CODEX_RING_ZERO_RESTRICTED_FEATURES = new Set([
"apps",
"chronicle",
"code_mode",
"code_mode_only",
"current_time_reminder",
"deferred_executor",
"enable_fanout",
"goals",
"hooks",
"image_generation",
@@ -111,8 +117,21 @@ const CODEX_RING_ZERO_RESTRICTED_FEATURES = new Set([
"multi_agent",
"multi_agent_v2",
"plugins",
"skill_search",
"shell_tool",
"standalone_web_search",
"token_budget",
"unified_exec",
"view_image",
]);
const CODEX_RING_ZERO_RESTRICTED_FEATURE_ALIASES = new Map<string, string>([
["connectors", "apps"],
["imagegenext", "image_generation"],
["collab", "multi_agent"],
["memory_tool", "memories"],
["telepathy", "chronicle"],
["codex_hooks", "hooks"],
]);
const CODEX_RING_ZERO_OVERRIDABLE_LAYER_TYPES = new Set([
@@ -547,7 +566,8 @@ export async function assertCodexRestrictedToolSurfaceHasNoManagedHooks(
if (typeof enabled !== "boolean") {
throw new Error("Codex configRequirements/read returned invalid feature requirements");
}
if (enabled && CODEX_RING_ZERO_RESTRICTED_FEATURES.has(feature)) {
const canonicalFeature = CODEX_RING_ZERO_RESTRICTED_FEATURE_ALIASES.get(feature) ?? feature;
if (enabled && CODEX_RING_ZERO_RESTRICTED_FEATURES.has(canonicalFeature)) {
throw new Error(
`Codex restricted tool surface cannot override required feature ${feature}`,
);
+6 -1
View File
@@ -536,7 +536,12 @@ async function runSerializedClaudeTurn(
const abort = () =>
abortClaudeTurn(session, createAbortError(params.context.params.abortSignal?.reason));
const replyBackendHandle: ReplyBackendHandle | undefined = params.context.params.replyOperation
? { kind: "cli", runId: params.context.params.runId, cancel: abort }
? {
kind: "cli",
runId: params.context.params.runId,
toolAuthorityFingerprint: params.context.params.toolAuthorityFingerprint,
cancel: abort,
}
: undefined;
params.context.params.abortSignal?.addEventListener("abort", abort, { once: true });
if (replyBackendHandle) {
@@ -201,6 +201,7 @@ export async function executeNodeClaudeRun(params: {
? {
kind: "cli" as const,
runId: contextParams.runId,
toolAuthorityFingerprint: contextParams.toolAuthorityFingerprint,
cancel: abortNodeRun,
}
: undefined;
+1
View File
@@ -287,6 +287,7 @@ export async function executeCliProcess(params: {
? {
kind: "cli" as const,
runId: runParams.runId,
toolAuthorityFingerprint: runParams.toolAuthorityFingerprint,
cancel: () => managedRun.cancel("manual-cancel"),
}
: undefined;
+2
View File
@@ -142,6 +142,8 @@ export type RunCliAgentParams = {
*/
runTimeoutOverrideMs?: number;
runId: string;
/** Exact attempt authority attached to the active steering backend. */
toolAuthorityFingerprint?: string;
/** Immutable lifecycle ownership captured when this execution was admitted. */
lifecycleGeneration?: string;
lane?: string;
@@ -9,12 +9,14 @@ import type { SessionToolOverrides } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
import type { ContextEngine, ContextEngineRuntimeContext } from "../../context-engine/types.js";
import type { RuntimePluginToolGrant } from "../../plugins/runtime/tool-grant.js";
import type { CommandQueueEnqueueFn } from "../../process/command-queue.types.js";
import type { InputProvenance } from "../../sessions/input-provenance.js";
import type { SkillSnapshot } from "../../skills/types.js";
import type { ExecElevatedDefaults, ExecToolDefaults } from "../bash-tools.exec-types.js";
import type { AgentRunSessionTarget } from "../run-session-target.js";
import type { AgentRuntimeAuthPlan, AgentRuntimePlan } from "../runtime-plan/types.js";
import type { ScheduledToolPolicyContext } from "../scheduled-tool-policy.js";
import type { TrustedSubagentCompletionHandoff } from "../subagents/announce/subagent-announce-handoff.js";
export type CompactEmbeddedAgentSessionParams = {
@@ -52,11 +54,18 @@ export type CompactEmbeddedAgentSessionParams = {
groupChannel?: string | null;
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
groupSpace?: string | null;
memberRoleIds?: string[];
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
inputProvenance?: InputProvenance;
/** Consumed in-process subagent-completion capability; never derived from public input. */
trustedInternalHandoff?: TrustedSubagentCompletionHandoff;
toolsAllow?: string[];
disableTools?: boolean;
runtimePluginToolGrant?: RuntimePluginToolGrant;
scheduledToolPolicy?: ScheduledToolPolicyContext;
/** Host-resolved ambient native-tool boundary for this compaction operation. */
nativeToolSurface?: "unrestricted" | "host-isolated";
sessionFile: string;
/** Optional caller-observed live prompt tokens used for compaction diagnostics. */
currentTokenCount?: number;
@@ -29,6 +29,8 @@ import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
export type EmbeddedAgentQueueHandle = {
kind?: "embedded";
runId?: string;
/** Exact authority of the concrete provider/model attempt behind this handle. */
toolAuthorityFingerprint?: string;
queueMessage: (
text: string,
options?: EmbeddedAgentQueueMessageOptions,
@@ -426,6 +426,9 @@ export function prepareEmbeddedAttemptStream(input: {
const queueHandle: AttemptStreamQueueHandle = {
kind: "embedded",
runId: attempt.runId,
...(attempt.toolAuthorityFingerprint
? { toolAuthorityFingerprint: attempt.toolAuthorityFingerprint }
: {}),
queueMessage,
messageInjection: {
isAvailable: () =>
@@ -271,6 +271,8 @@ export type RunEmbeddedAgentParams = {
bootstrapContextRunKind?: BootstrapContextRunKind;
/** Optional tool allow-list; when set, only these tools are sent to the model. */
toolsAllow?: string[];
/** Exact attempt authority attached to the active steering backend. */
toolAuthorityFingerprint?: string;
/** Owner-scoped plugin tool grant; normal policy and deny rules still apply. */
runtimePluginToolGrant?: RuntimePluginToolGrant;
/** Consumed in-process subagent-completion capability; never derived from public input. */
+6 -1
View File
@@ -75,6 +75,7 @@ type EmbeddedAgentQueueFailureReason =
| "not_streaming"
| "stale_run"
| "compacting"
| "tool_authority_mismatch"
| "image_input_unsupported"
| "source_reply_delivery_mode_mismatch"
| "task_suggestion_delivery_mode_mismatch"
@@ -561,7 +562,11 @@ function prepareEmbeddedAgentQueueMessage(
outcome: createQueueFailureOutcome(sessionId, "transcript_commit_wait_unsupported"),
};
}
const deliveryModeMismatch = resolveReplyBackendQueueMessageMismatch(handle, options);
const deliveryModeMismatch = resolveReplyBackendQueueMessageMismatch(
handle,
options,
resolveActiveReplyOperationForSessionId(sessionId),
);
if (deliveryModeMismatch) {
diag.debug(`queue message failed: sessionId=${sessionId} reason=${deliveryModeMismatch}`);
return {
+24 -2
View File
@@ -35,6 +35,7 @@ import {
import type { AgentRuntimeAuthPlan } from "../runtime-plan/types.js";
import { resolveAgentHarnessPolicy as resolveConfiguredAgentHarnessPolicy } from "./policy.js";
import {
resolveAgentHarnessNativeToolPolicyRestricted,
selectAgentHarness,
selectAgentHarnessForPreparedModelProviders,
type AgentHarnessPreparedModelProvider,
@@ -403,7 +404,7 @@ export async function maybeCompactAgentHarnessSession(
!params.model ||
!agentRuntimeAuthPlanMatchesTarget(runtimeAuthPlan, {
provider: params.provider,
modelId: params.model,
modelId: params.model ?? "",
}))
) {
throw new Error(
@@ -465,7 +466,17 @@ export async function maybeCompactAgentHarnessSession(
}
const compactIdentity = resolveHarnessCompactIdentity(params);
let resolvedRuntimeAuthPlan = runtimeAuthPlan;
const compactParams = {
const resolveNativeToolPolicyRestricted = (targetHarness: AgentHarness) =>
resolveAgentHarnessNativeToolPolicyRestricted(
{
...params,
agentId: compactIdentity.agentId,
provider: params.provider ?? "",
modelId: params.model ?? "",
},
targetHarness,
);
const compactParams: CompactEmbeddedAgentSessionParams = {
...params,
agentDir: compactIdentity.agentDir,
agentId: compactIdentity.agentId,
@@ -487,6 +498,8 @@ export async function maybeCompactAgentHarnessSession(
pinnedHarnessId,
});
harness = resolved.harness;
const nativeToolPolicyRestricted = resolveNativeToolPolicyRestricted(harness);
compactParams.nativeToolSurface = nativeToolPolicyRestricted ? "host-isolated" : "unrestricted";
resolvedRuntimeAuthPlan = resolved.runtimeAuthPlan ?? resolvedRuntimeAuthPlan;
const internalHarness = harness as InternalAgentHarness;
const shouldCompactAfterContextEngine =
@@ -505,6 +518,15 @@ export async function maybeCompactAgentHarnessSession(
}
return undefined;
}
if (
nativeToolPolicyRestricted &&
harness.id !== "openclaw" &&
harness.conversationToolPolicySupport !== "exact"
) {
throw new Error(
`Agent harness ${harness.id} cannot enforce the host-isolated tool policy required for compaction`,
);
}
// Native runtimes own subscription login, but a provider-locked Platform
// route must receive the exact host-prepared key selected for this attempt.
const harnessOwnsAuth =
+26
View File
@@ -161,6 +161,7 @@ type PluginHarnessToolPolicyContext = Pick<
| "groupId"
| "groupChannel"
| "groupSpace"
| "memberRoleIds"
| "agentAccountId"
| "senderId"
| "senderName"
@@ -170,6 +171,9 @@ type PluginHarnessToolPolicyContext = Pick<
| "inputProvenance"
| "trustedInternalHandoff"
| "scheduledToolPolicy"
| "runtimePluginToolGrant"
| "toolsAllow"
| "disableTools"
>;
type PluginHarnessToolPolicy = { allow?: string[]; deny?: string[] };
@@ -859,6 +863,19 @@ export function resolvePluginHarnessPolicyToolsAllow(
: undefined;
}
/** Resolves whether a harness operation must remove its ambient native tool surface. */
export function resolveAgentHarnessNativeToolPolicyRestricted(
params: PluginHarnessToolPolicyContext,
harness: AgentHarness,
): boolean {
return resolvePluginHarnessToolPolicies(
params,
harness.conversationToolPolicySupport === "exact"
? harness.conversationToolPolicySafeDenyTools
: undefined,
).toolPolicyRestricted;
}
function resolvePluginHarnessDenyAllToolPolicyPrompt(
policies: ResolvedPluginHarnessToolPolicies,
): string | undefined {
@@ -902,6 +919,7 @@ function resolvePluginHarnessToolPolicies(
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
memberRoleIds: params.memberRoleIds,
spawnedBy: params.spawnedBy,
senderId: params.senderId,
senderName: params.senderName,
@@ -912,6 +930,7 @@ function resolvePluginHarnessToolPolicies(
inputProvenance: params.inputProvenance,
trustedInternalHandoff: params.trustedInternalHandoff,
scheduledToolPolicy: params.scheduledToolPolicy,
runtimePluginToolGrant: params.runtimePluginToolGrant,
});
const groupPolicyParams = {
config: params.config,
@@ -930,6 +949,11 @@ function resolvePluginHarnessToolPolicies(
senderPolicyMode: params.scheduledToolPolicy ? ("never" as const) : ("always" as const),
};
const { policy } = capabilityProfile;
const requestedToolPolicy = params.disableTools
? { allow: [] }
: params.toolsAllow
? { allow: params.toolsAllow }
: undefined;
const explicitPolicies = [
policy.globalPolicy,
policy.globalProviderPolicy,
@@ -941,6 +965,7 @@ function resolvePluginHarnessToolPolicies(
policy.subagentPolicy,
policy.inheritedToolPolicy,
policy.runtimeToolPolicyForInheritance,
requestedToolPolicy,
];
const safeDenyToolNameSet = safeDenyToolNames
? new Set(safeDenyToolNames.map(normalizeToolPolicyName))
@@ -963,6 +988,7 @@ function resolvePluginHarnessToolPolicies(
sandboxPolicy,
policy.subagentPolicy,
policy.inheritedToolPolicy,
requestedToolPolicy,
],
toolPolicyRestricted: explicitPolicies.some((explicitPolicy) =>
toolPolicyRestrictsHarnessNativeTools(explicitPolicy, safeDenyToolNameSet),
@@ -42,6 +42,7 @@ import { hasInboundAudio } from "./inbound-media.js";
import { resolveOriginMessageProvider } from "./origin-routing.js";
import type { FollowupRun } from "./queue.js";
import { isReplyOperationRestartAbort } from "./reply-operation-abort.js";
import { resolveFollowupRunToolAuthorityFingerprint } from "./reply-tool-authority.js";
type CliPresentation = Pick<
ReturnType<typeof createAgentTurnPresentation>,
@@ -437,6 +438,10 @@ export async function runCliFallbackCandidate(params: {
approvalReviewerDeviceId: turn.followupRun.run.approvalReviewerDeviceId,
toolsAllow: turn.opts?.toolsAllow,
disableTools: turn.opts?.disableTools,
toolAuthorityFingerprint: resolveFollowupRunToolAuthorityFingerprint(turn.followupRun, {
provider: params.provider,
model: params.model,
}),
abortSignal: params.runAbortSignal,
onExecutionPhase: params.signalExecutionPhaseForTyping,
replyOperation: turn.replyOperation,
@@ -148,6 +148,7 @@ function createReplyOperation(): TestReplyOperation {
}),
updateSessionKey: vi.fn(),
hasOwnedSessionId: vi.fn(() => false),
bindToolAuthorityFingerprint: vi.fn(),
attachBackend: vi.fn(),
detachBackend: vi.fn(),
retainFailureUntilComplete: vi.fn(),
@@ -43,6 +43,7 @@ import { buildEmbeddedRunExecutionParams } from "./agent-runner-utils.js";
import type { FollowupRun } from "./queue.js";
import { isReplyOperationRestartAbort } from "./reply-operation-abort.js";
import { markReplyOperationGlobalLaneWaitProgress } from "./reply-run-registry.js";
import { resolveFollowupRunToolAuthorityFingerprint } from "./reply-tool-authority.js";
import {
bindSourceReplyDeliveryRuntime,
readSourceReplyDeliveryRuntime,
@@ -257,6 +258,10 @@ export async function runEmbeddedFallbackCandidate(params: {
turn.opts?.shouldSuppressToolErrorWarnings ?? turn.opts?.suppressToolErrorWarnings,
toolsAllow: turn.opts?.toolsAllow,
disableTools: turn.opts?.disableTools,
toolAuthorityFingerprint: resolveFollowupRunToolAuthorityFingerprint(turn.followupRun, {
provider: embeddedRunProvider,
model: params.model,
}),
enableHeartbeatTool: turn.opts?.enableHeartbeatTool,
forceHeartbeatTool: turn.opts?.forceHeartbeatTool,
bootstrapContextMode: turn.opts?.bootstrapContextMode,
@@ -520,6 +520,7 @@ export function createMockReplyOperation(options?: { abortSignal?: AbortSignal }
markGlobalLaneWaitEnded: vi.fn(),
updateSessionId: updateSessionIdMock,
updateSessionKey: vi.fn(),
bindToolAuthorityFingerprint: vi.fn(),
attachBackend: vi.fn(),
detachBackend: vi.fn(),
freezeAbort: freezeAbortMock,
@@ -95,6 +95,7 @@ function createReplyOperation(): TestReplyOperation {
setPhase: vi.fn<ReplyOperation["setPhase"]>(),
updateSessionId: vi.fn<ReplyOperation["updateSessionId"]>(),
updateSessionKey: vi.fn<ReplyOperation["updateSessionKey"]>(),
bindToolAuthorityFingerprint: vi.fn(),
attachBackend: vi.fn(),
detachBackend: vi.fn(),
freezeAbort: vi.fn(),
@@ -99,6 +99,9 @@ export function buildEmbeddedRunBaseParams(params: {
skillsSnapshot: params.run.skillsSnapshot,
ownerNumbers: params.run.ownerNumbers,
inputProvenance: params.run.inputProvenance,
trustedInternalHandoff: params.run.trustedInternalHandoff,
scheduledToolPolicy: params.run.scheduledToolPolicy,
runtimePluginToolGrant: params.run.runtimePluginToolGrant,
senderIsOwner: params.run.senderIsOwner,
conversationToolPolicy: params.run.conversationToolPolicy,
channelContext: params.run.channelContext,
+27 -5
View File
@@ -48,6 +48,7 @@ import * as replyRunState from "./reply-operation-run-state.js";
import { type ReplyOperation, replyRunRegistry } from "./reply-run-registry.js";
import { bindReplyOperationTyping } from "./reply-run-typing.js";
import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js";
import { resolveFollowupRunToolAuthorityFingerprint } from "./reply-tool-authority.js";
import { admitReplyTurn, resolveReplyTurnKind } from "./reply-turn-admission.js";
import {
isDuplicateRestartRecoverySource,
@@ -133,6 +134,20 @@ export async function runReplyAgent(
});
const effectiveShouldSteer = !isHeartbeat && !effectiveResetTriggered && shouldSteer;
const effectiveShouldFollowup = !effectiveResetTriggered && shouldFollowup;
const incomingToolAuthorityFingerprint = resolveFollowupRunToolAuthorityFingerprint(followupRun);
const activeReplyOperation = sessionKey
? (replyRunRegistry.get(sessionKey) ?? providedReplyOperation)
: providedReplyOperation;
const shouldQueueAuthorityMismatch =
effectiveShouldSteer &&
isActive &&
activeReplyOperation !== undefined &&
activeReplyOperation?.toolAuthorityFingerprint !== incomingToolAuthorityFingerprint;
if (shouldQueueAuthorityMismatch) {
logVerbose(
`queue: active session ${activeReplyOperation?.sessionId ?? followupRun.run.sessionId} has different or unknown tool authority; queuing instead of steering`,
);
}
const typingSignals = createTypingSignaler({
typing,
mode: typingMode,
@@ -223,7 +238,12 @@ export async function runReplyAgent(
toolProgressDetail,
});
if (effectiveShouldSteer && isActive && opts?.messageInjectionAttempted !== true) {
if (
effectiveShouldSteer &&
isActive &&
!shouldQueueAuthorityMismatch &&
opts?.messageInjectionAttempted !== true
) {
replyRunState.bindQueueDispositionToRunState(followupRun, replyOperationRunState);
await runActiveReplySteer({
followupRun,
@@ -240,6 +260,7 @@ export async function runReplyAgent(
touchActiveSessionEntry,
typing,
typingSignals,
toolAuthorityFingerprint: incomingToolAuthorityFingerprint,
});
return undefined;
}
@@ -248,7 +269,7 @@ export async function runReplyAgent(
queueAdmissionState,
isActive,
isHeartbeat,
shouldFollowup: effectiveShouldFollowup,
shouldFollowup: effectiveShouldFollowup || shouldQueueAuthorityMismatch,
queueMode: activeRunQueueMode,
resetTriggered: effectiveResetTriggered,
});
@@ -281,10 +302,10 @@ export async function runReplyAgent(
}
// The queue must stay dormant while the active owner can still collect
// messages. Registering after enqueue closes the owner-clear race.
const activeReplyOperation = replyRunRegistry.get(queueKey);
if (activeReplyOperation) {
const queuedOperationOwner = replyRunRegistry.get(queueKey);
if (queuedOperationOwner) {
scheduleFollowupDrainAfterReplyOperationClear({
operation: activeReplyOperation,
operation: queuedOperationOwner,
queueKey,
runFollowup: queuedRunFollowupTurn,
});
@@ -442,6 +463,7 @@ export async function runReplyAgent(
}
}
}
replyOperation.bindToolAuthorityFingerprint(incomingToolAuthorityFingerprint);
bindReplyOperationTyping(replyOperation, typing);
let runFollowupTurn = queuedRunFollowupTurn;
let shouldDrainQueuedFollowupsAfterClear = false;
@@ -38,6 +38,7 @@ type ActiveReplySteerParams = {
touchActiveSessionEntry: () => Promise<void>;
typing: RunReplyAgentParams["typing"];
typingSignals: TypingSignaler;
toolAuthorityFingerprint: string;
};
function resolveAcceptedSteerRunId(params: ActiveReplySteerParams): string {
@@ -183,6 +184,7 @@ export async function runActiveReplySteer(params: ActiveReplySteerParams): Promi
{
steeringMode: "all",
isInboundUserMessage: true,
toolAuthorityFingerprint: params.toolAuthorityFingerprint,
...(followupRun.images?.length ? { images: followupRun.images } : {}),
...(followupRun.imageOrder?.length ? { imageOrder: followupRun.imageOrder } : {}),
...(followupRun.media?.length ? { media: followupRun.media } : {}),
@@ -148,6 +148,19 @@ describe("agent-runner-utils", () => {
enforceFinalTag: true,
cwd: "/tmp/task-repo",
taskSuggestionDeliveryMode: "gateway",
trustedInternalHandoff: {
kind: "subagent-completion",
sourceSessionKey: "agent:child",
targetSessionKey: "agent:parent",
targetSessionId: "session-1",
provider: "openai",
model: "gpt-5.6-luna",
},
scheduledToolPolicy: { version: 1, mode: "trusted" },
runtimePluginToolGrant: {
pluginId: "workboard",
toolNames: ["workboard_complete"],
},
});
const authProfile = resolveProviderScopedAuthProfile({
provider: "openai",
@@ -172,6 +185,9 @@ describe("agent-runner-utils", () => {
expect(resolved.config).toBe(run.config);
expect(resolved.skillsSnapshot).toBe(run.skillsSnapshot);
expect(resolved.ownerNumbers).toBe(run.ownerNumbers);
expect(resolved.trustedInternalHandoff).toBe(run.trustedInternalHandoff);
expect(resolved.scheduledToolPolicy).toBe(run.scheduledToolPolicy);
expect(resolved.runtimePluginToolGrant).toBe(run.runtimePluginToolGrant);
expect(resolved.enforceFinalTag).toBe(true);
expect(resolved.provider).toBe("openai");
expect(resolved.model).toBe("gpt-4.1-mini");
@@ -46,6 +46,7 @@ import {
} from "./reply-run-registry.js";
import { testing as replyRunTesting } from "./reply-run-registry.test-support.js";
import { bindReplyOperationTyping } from "./reply-run-typing.js";
import { resolveFollowupRunToolAuthorityFingerprint } from "./reply-tool-authority.js";
import { consumeReplyUsageState } from "./reply-usage-state.js";
import { buildChannelSourceTurnId, setChannelSourceTurnId } from "./source-turn-id.js";
import { createMockTypingController } from "./test-helpers.js";
@@ -354,6 +355,7 @@ function createMinimalRun(params?: {
sessionCtx?: Partial<TemplateContext>;
sourceTurnId?: string;
runOverrides?: Partial<FollowupRun["run"]>;
bindActiveAuthority?: boolean;
}) {
const typing = createMockTypingController();
const opts = params?.opts;
@@ -408,6 +410,12 @@ function createMinimalRun(params?: {
...params?.runOverrides,
},
} as unknown as FollowupRun;
const activeOperation = replyRunRegistry.get(sessionKey);
if (activeOperation && params?.bindActiveAuthority !== false) {
activeOperation.bindToolAuthorityFingerprint(
resolveFollowupRunToolAuthorityFingerprint(followupRun),
);
}
return {
followupRun,
@@ -524,6 +532,34 @@ function requireBuiltChannelSourceTurnId(
}
describe("runReplyAgent active steering", () => {
it("queues instead of steering when the incoming turn has different tool authority", async () => {
const active = createReplyOperation({
sessionKey: "main",
sessionId: "session",
resetTriggered: false,
});
active.bindToolAuthorityFingerprint("different-authority");
active.setPhase("running");
const { run } = createMinimalRun({
isActive: true,
shouldSteer: true,
resolvedQueueMode: "steer",
bindActiveAuthority: false,
runOverrides: {
runtimePluginToolGrant: {
pluginId: "workboard",
toolNames: ["workboard_complete"],
},
},
});
await expect(run()).resolves.toBeUndefined();
expect(state.queueEmbeddedAgentMessageMock).not.toHaveBeenCalled();
expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledOnce();
active.complete();
});
it("keeps the continuing Telegram task's typing alive after an accepted steer", async () => {
state.queueEmbeddedAgentMessageMock.mockReturnValueOnce(true);
const active = createReplyOperation({
@@ -48,6 +48,8 @@ function buildFollowupTemplateContext(turn: AdmittedFollowupTurn): TemplateConte
MessageThreadId: queued.originatingThreadId,
ReplyToId: queued.originatingReplyToId,
SenderId: run.senderId,
MemberRoleIds: run.memberRoleIds,
ChannelContext: run.channelContext,
SenderName: run.senderName,
SenderUsername: run.senderUsername,
SenderE164: run.senderE164,
@@ -179,6 +181,10 @@ export async function executeFollowupTurn(params: {
};
const progressOpts: InternalGetReplyOptions = {
...sourceOpts,
// Queue callbacks are refreshed per session, but authority belongs to the
// queued turn. Never let a later callback widen or narrow an older item.
toolsAllow: turn.queued.toolsAllow,
disableTools: turn.queued.disableTools,
runId: turn.runId,
onAgentRunStart: (runId) => {
params.onExecutionStarted?.();
@@ -12,6 +12,10 @@ import {
import { resolveFastModeState } from "../../agents/fast-mode.js";
import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js";
import { resolveOwnerPromptNumbers } from "../../agents/owner-display.js";
import {
attachToolAllowlistIntersection,
readToolAllowlistIntersection,
} from "../../agents/tool-policy.js";
import { conversationIdentityFromMsgContext } from "../../config/sessions/conversation-identity.js";
import { resolveGroupSessionKey } from "../../config/sessions/group.js";
import { normalizeMediaFacts } from "../../media/media-facts.js";
@@ -323,6 +327,13 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
const replyPolicyChannel =
(replyRoute.channel as OriginatingChannelType | undefined) ??
(messageProvider as OriginatingChannelType | undefined);
const queuedToolsAllow = opts?.toolsAllow ? [...opts.toolsAllow] : opts?.toolsAllow;
const queuedToolIntersections = opts?.toolsAllow
? readToolAllowlistIntersection(opts.toolsAllow)
: undefined;
if (queuedToolsAllow && queuedToolIntersections) {
attachToolAllowlistIntersection(queuedToolsAllow, queuedToolIntersections);
}
const followupRun = {
prompt: queuedBody,
transcriptPrompt: transcriptCommandBody,
@@ -340,6 +351,8 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
: {}),
messageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid,
summaryLine: baseBodyTrimmedRaw,
...(queuedToolsAllow !== undefined ? { toolsAllow: queuedToolsAllow } : {}),
...(opts?.disableTools !== undefined ? { disableTools: opts.disableTools } : {}),
enqueuedAt: Date.now(),
images: currentTurnImages.images,
imageOrder: currentTurnImages.imageOrder,
@@ -374,6 +387,11 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
normalizeOptionalString(sessionCtx.GroupChannel) ??
normalizeOptionalString(sessionCtx.GroupSubject),
groupSpace: normalizeOptionalString(sessionCtx.GroupSpace),
memberRoleIds: Array.isArray(sessionCtx.MemberRoleIds)
? sessionCtx.MemberRoleIds.map((roleId) => normalizeOptionalString(roleId)).filter(
(roleId): roleId is string => Boolean(roleId),
)
: undefined,
// Parent lineage authenticates inherited group policy for queued CLI/MCP runs.
spawnedBy: normalizeOptionalString(preparedSessionState.sessionEntry?.spawnedBy),
senderId: normalizeOptionalString(sessionCtx.SenderId),
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import { attachToolAllowlistIntersection } from "../../agents/tool-policy.js";
import {
loadTranscriptEvents,
replaceSessionEntry,
@@ -2102,6 +2103,75 @@ describe("followup queue collect routing", () => {
expect(calls[1]?.prompt).not.toContain("first");
});
it("splits collect batches when queued authority facts change", async () => {
const key = `test-collect-queued-authority-split-${Date.now()}`;
const { calls, done, runFollowup } = createDrainRecorder(3);
const settings: QueueSettings = { mode: "collect", debounceMs: 0 };
const route = { originatingChannel: "slack" as const, originatingTo: "channel:A" };
const pluginGrant = createRun({ prompt: "plugin grant", ...route });
pluginGrant.run.runtimePluginToolGrant = {
pluginId: "workboard",
toolNames: ["workboard_complete"],
};
const scheduled = createRun({ prompt: "scheduled authority", ...route });
scheduled.run.scheduledToolPolicy = { version: 1, mode: "trusted" };
const handoff = createRun({ prompt: "trusted handoff", ...route });
handoff.run.trustedInternalHandoff = {
kind: "subagent-completion",
sourceSessionKey: "agent:child",
targetSessionKey: "agent:parent",
targetSessionId: "session-1",
provider: "openai",
model: "gpt-5.6-luna",
};
enqueueFollowupRun(key, pluginGrant, settings);
enqueueFollowupRun(key, scheduled, settings);
enqueueFollowupRun(key, handoff, settings);
scheduleFollowupDrain(key, runFollowup);
await done.promise;
expect(calls.map((call) => call.prompt)).toEqual([
expect.stringContaining("plugin grant"),
expect.stringContaining("scheduled authority"),
expect.stringContaining("trusted handoff"),
]);
expect(calls[0]?.run.runtimePluginToolGrant).toEqual(pluginGrant.run.runtimePluginToolGrant);
expect(calls[1]?.run.scheduledToolPolicy).toEqual(scheduled.run.scheduledToolPolicy);
expect(calls[2]?.run.trustedInternalHandoff).toEqual(handoff.run.trustedInternalHandoff);
});
it("keys collect batches by turn allowlists, intersections, disablement, and roles", () => {
const createAuthorityRun = () =>
createRun({
prompt: "authority",
originatingChannel: "slack",
originatingTo: "channel:A",
});
const baseline = createAuthorityRun();
const toolsAllow = createAuthorityRun();
toolsAllow.toolsAllow = ["exec"];
const disabled = createAuthorityRun();
disabled.disableTools = true;
const roles = createAuthorityRun();
roles.run.memberRoleIds = ["operator"];
const firstIntersection = createAuthorityRun();
firstIntersection.toolsAllow = attachToolAllowlistIntersection(["exec"], [["exec"]]);
const secondIntersection = createAuthorityRun();
secondIntersection.toolsAllow = attachToolAllowlistIntersection(
["exec"],
[["exec"], ["message"]],
);
const baselineKey = resolveFollowupDeliveryContextKey(baseline);
expect(resolveFollowupDeliveryContextKey(toolsAllow)).not.toBe(baselineKey);
expect(resolveFollowupDeliveryContextKey(disabled)).not.toBe(baselineKey);
expect(resolveFollowupDeliveryContextKey(roles)).not.toBe(baselineKey);
expect(resolveFollowupDeliveryContextKey(firstIntersection)).not.toBe(
resolveFollowupDeliveryContextKey(secondIntersection),
);
});
it("keeps one collect batch when authorization context matches", async () => {
const { key, calls, done, runFollowup, settings } = createQueueCase(
`test-collect-auth-match-${Date.now()}`,
+10
View File
@@ -3,6 +3,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { stableStringify } from "@openclaw/normalization-core";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { runAgentHarnessBeforeMessageWriteHook } from "../../../agents/harness/hook-helpers.js";
import { readToolAllowlistIntersection } from "../../../agents/tool-policy.js";
import { normalizeChatType } from "../../../channels/chat-type.js";
import { resolveSessionStorePathCore } from "../../../config/sessions.js";
import { loadSessionEntryReadOnly } from "../../../config/sessions/session-accessor.js";
@@ -206,6 +207,7 @@ export function resolveFollowupDeliveryContextKey(run: FollowupRun): string {
execution.groupId ?? "",
execution.groupChannel ?? "",
execution.groupSpace ?? "",
JSON.stringify([...new Set(execution.memberRoleIds ?? [])].toSorted()),
execution.spawnedBy ?? "",
execution.traceAuthorized === true,
execution.elevatedLevel ?? "",
@@ -214,6 +216,14 @@ export function resolveFollowupDeliveryContextKey(run: FollowupRun): string {
provenance?.sourceSessionKey ?? "",
provenance?.sourceChannel ?? "",
provenance?.sourceTool ?? "",
stableStringify(execution.trustedInternalHandoff ?? null),
stableStringify(execution.scheduledToolPolicy ?? null),
stableStringify(execution.runtimePluginToolGrant ?? null),
stableStringify(run.toolsAllow ?? null),
stableStringify(
run.toolsAllow ? (readToolAllowlistIntersection(run.toolsAllow) ?? null) : null,
),
run.disableTools === true,
execution.extraSystemPrompt ?? "",
execution.extraSystemPromptStatic ?? "",
execution.sourceReplyDeliveryMode ?? "",
+11
View File
@@ -6,6 +6,8 @@ import type { ExecToolDefaults } from "../../../agents/bash-tools.js";
import type { CliSessionBindingFacts } from "../../../agents/cli-runner/types.js";
import type { CurrentInboundPromptContext } from "../../../agents/embedded-agent-runner/run/params.js";
import type { ModelFallbackRouteResolution } from "../../../agents/model-fallback.types.js";
import type { ScheduledToolPolicyContext } from "../../../agents/scheduled-tool-policy.js";
import type { TrustedSubagentCompletionHandoff } from "../../../agents/subagents/announce/subagent-announce-handoff.js";
import type { SilentReplyPromptMode } from "../../../agents/system-prompt.types.js";
import type { ChatType } from "../../../channels/chat-type.js";
import type { InboundEventKind } from "../../../channels/inbound-event/kind.js";
@@ -16,6 +18,7 @@ import type { GroupToolPolicyConfig } from "../../../config/types.tools.js";
import type { MediaFact } from "../../../media/media-facts.js";
import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js";
import type { PluginHookChannelContext } from "../../../plugins/hook-types.js";
import type { RuntimePluginToolGrant } from "../../../plugins/runtime/tool-grant.js";
import type { InputProvenance } from "../../../sessions/input-provenance.js";
import type { UserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.types.js";
import type { ExplicitSkillSelection, SkillSnapshot } from "../../../skills/types.js";
@@ -99,6 +102,9 @@ export type FollowupRun = {
/** Provider message ID, when available (for deduplication). */
messageId?: string;
summaryLine?: string;
/** Turn-owned tool authority captured before queue ownership transfers. */
toolsAllow?: string[];
disableTools?: boolean;
/** Force individual drain; never merge this run into a collect batch. */
disableCollectBatching?: boolean;
/** The current-turn hook already ran before this steer became a fallback. */
@@ -156,6 +162,7 @@ export type FollowupRun = {
groupId?: string;
groupChannel?: string;
groupSpace?: string;
memberRoleIds?: string[];
/** Parent session provenance used to validate inherited group policy. */
spawnedBy?: string;
senderId?: string;
@@ -205,6 +212,10 @@ export type FollowupRun = {
blockReplyBreak: "text_end" | "message_end";
ownerNumbers?: string[];
inputProvenance?: InputProvenance;
/** Trusted authority facts that must survive queueing and steering admission. */
trustedInternalHandoff?: TrustedSubagentCompletionHandoff;
scheduledToolPolicy?: ScheduledToolPolicyContext;
runtimePluginToolGrant?: RuntimePluginToolGrant;
extraSystemPrompt?: string;
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode;
@@ -19,6 +19,8 @@ export type ReplyBackendQueueMessageOptions = {
steeringMode?: "all";
/** True when this queue item came from the channel's current user turn. */
isInboundUserMessage?: boolean;
/** Exact tool authority resolved for an inbound user turn before steering. */
toolAuthorityFingerprint?: string;
debounceMs?: number;
/** Ordered current-turn images to inject with the steering text. */
images?: ImageContent[];
@@ -56,6 +58,8 @@ export type ReplyBackendMessageInjection = {
export type ReplyBackendHandle = {
readonly kind: ReplyBackendKind;
readonly runId?: string;
/** Exact authority of this concrete backend attempt, after fallback selection. */
readonly toolAuthorityFingerprint?: string;
readonly sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
readonly taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode;
/** True only when queueMessage preserves images supplied in its options. */
@@ -113,6 +117,7 @@ export type ReplyMessageInjectionAttempt = {
};
type ReplyBackendQueueMessageMismatch =
| "tool_authority_mismatch"
| "image_input_unsupported"
| "source_reply_delivery_mode_mismatch"
| "task_suggestion_delivery_mode_mismatch";
@@ -167,6 +172,8 @@ export type ReplyOperation = {
* Final delivery reads it because the original dispatch context cannot change.
*/
readonly acceptedSteeredInboundAudio: boolean;
/** Immutable tool authority accepted by the active backend for steered user turns. */
readonly toolAuthorityFingerprint?: string;
readonly phase: ReplyOperationPhase;
readonly result: ReplyOperationResult | null;
/** Set when a stale-watchdog expiry forced this operation's run_stalled result. */
@@ -196,6 +203,8 @@ export type ReplyOperation = {
/** Mark this operation as an in-flight terminal-session recovery. */
markTerminalRecovery(): void;
markAcceptedSteeredInboundAudio(): void;
/** Bind provisional request authority before a concrete backend attempt attaches. */
bindToolAuthorityFingerprint(fingerprint: string): void;
updateSessionId(nextSessionId: string): void;
/**
* Move this queued operation to another session key's run slot. Native command
@@ -18,6 +18,7 @@ import {
} from "./reply-run-registry.state.js";
type ReplyBackendQueueMessageMismatch =
| "tool_authority_mismatch"
| "image_input_unsupported"
| "source_reply_delivery_mode_mismatch"
| "task_suggestion_delivery_mode_mismatch";
@@ -35,10 +36,23 @@ type ReplyMessageInjectionRejectionReason =
export function resolveReplyBackendQueueMessageMismatch(
backend: Pick<
ReplyBackendHandle,
"sourceReplyDeliveryMode" | "supportsQueueMessageImages" | "taskSuggestionDeliveryMode"
| "sourceReplyDeliveryMode"
| "supportsQueueMessageImages"
| "taskSuggestionDeliveryMode"
| "toolAuthorityFingerprint"
>,
options?: ReplyBackendQueueMessageOptions,
authority?: { toolAuthorityFingerprint?: string },
): ReplyBackendQueueMessageMismatch | undefined {
if (options?.isInboundUserMessage === true) {
const activeFingerprint = normalizeOptionalString(
backend.toolAuthorityFingerprint ?? authority?.toolAuthorityFingerprint,
);
const incomingFingerprint = normalizeOptionalString(options.toolAuthorityFingerprint);
if (!activeFingerprint || !incomingFingerprint || activeFingerprint !== incomingFingerprint) {
return "tool_authority_mismatch";
}
}
if (options?.images?.length && backend.supportsQueueMessageImages !== true) {
return "image_input_unsupported";
}
@@ -123,7 +137,7 @@ export function resolveReplyMessageInjectionRejection(params: {
} catch (error) {
return { reason: "injection_unavailable", errorMessage: String(error) };
}
const mismatch = resolveReplyBackendQueueMessageMismatch(backend, params.options);
const mismatch = resolveReplyBackendQueueMessageMismatch(backend, params.options, operation);
return mismatch ? { reason: mismatch } : { backend, injection };
}
@@ -94,6 +94,7 @@ export function createReplyOperation(params: {
let retainFailureUntilComplete = false;
let terminalRecovery = false;
let acceptedSteeredInboundAudio = false;
let toolAuthorityFingerprint: string | undefined;
const ownerSettlement = createDeferredCore();
let ownerSettled = false;
const settleOwner = () => {
@@ -237,6 +238,9 @@ export function createReplyOperation(params: {
get acceptedSteeredInboundAudio() {
return acceptedSteeredInboundAudio;
},
get toolAuthorityFingerprint() {
return toolAuthorityFingerprint;
},
get phase() {
return phase;
},
@@ -320,6 +324,16 @@ export function createReplyOperation(params: {
markAcceptedSteeredInboundAudio() {
acceptedSteeredInboundAudio = true;
},
bindToolAuthorityFingerprint(fingerprint) {
const normalized = normalizeOptionalString(fingerprint);
if (!normalized) {
throw new Error("Reply operation tool authority fingerprint is required");
}
if (toolAuthorityFingerprint && toolAuthorityFingerprint !== normalized) {
throw new Error("Reply operation cannot change tool authority after admission");
}
toolAuthorityFingerprint = normalized;
},
updateSessionId(nextSessionId) {
if (result) {
return;
@@ -406,6 +420,12 @@ export function createReplyOperation(params: {
return;
}
recordActivity();
const backendToolAuthorityFingerprint = normalizeOptionalString(
handle.toolAuthorityFingerprint,
);
if (backendToolAuthorityFingerprint) {
toolAuthorityFingerprint = backendToolAuthorityFingerprint;
}
attachedBackendByOperation.set(operation, handle);
if (controller.signal.aborted) {
handle.cancel("superseded");
@@ -3,6 +3,7 @@ import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coerci
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import { createAgentRunRestartAbortError } from "../../agents/run-termination.js";
import { attachToolAllowlistIntersection } from "../../agents/tool-policy.js";
import {
getDiagnosticSessionActivitySnapshot,
markDiagnosticEmbeddedRunStarted,
@@ -13,6 +14,7 @@ import { markDiagnosticModelStartedForTest } from "../../logging/diagnostic-run-
import { diagnosticLogger } from "../../logging/diagnostic-runtime.js";
import { enqueueCommandInLane, setCommandLaneConcurrency } from "../../process/command-queue.js";
import { resetCommandQueueStateForTest } from "../../process/command-queue.test-support.js";
import { createQueueTestRun } from "./queue.test-helpers.js";
import { beginReplyOperationFinalizationWork } from "./reply-run-finalization-lease.js";
import {
abortActiveReplyRuns,
@@ -44,6 +46,7 @@ import {
waitForReplyRunSuccessorAdmission,
} from "./reply-run-registry.js";
import { testing } from "./reply-run-registry.test-support.js";
import { resolveFollowupRunToolAuthorityFingerprint } from "./reply-tool-authority.js";
import { admitReplyTurn } from "./reply-turn-admission.js";
const REPLY_RUN_FINALIZATION_SETTLE_TIMEOUT_MS = 60_000;
@@ -93,6 +96,17 @@ async function withFakeReplyTimers<T>(run: () => Promise<T>): Promise<T> {
}
describe("reply run registry", () => {
it("distinguishes hidden allowlist intersections in steering authority", () => {
const first = createQueueTestRun({ prompt: "first" });
const second = createQueueTestRun({ prompt: "second" });
first.toolsAllow = attachToolAllowlistIntersection(["exec"], [["exec"]]);
second.toolsAllow = attachToolAllowlistIntersection(["exec"], [["exec"], ["message"]]);
expect(resolveFollowupRunToolAuthorityFingerprint(first)).not.toBe(
resolveFollowupRunToolAuthorityFingerprint(second),
);
});
afterEach(() => {
testing.resetReplyRunRegistry();
resetCommandQueueStateForTest();
@@ -1780,6 +1794,34 @@ describe("reply run registry", () => {
);
});
it("rejects inbound steering when tool authority changes before backend admission", async () => {
const queueMessage = vi.fn(async () => {});
const operation = createTestReplyOperation({ sessionId: "session-authority" });
operation.bindToolAuthorityFingerprint("authority-a");
operation.attachBackend({
kind: "embedded",
cancel: vi.fn(),
isStreaming: () => true,
queueMessage,
});
operation.setPhase("running");
await expect(
queueCurrentReplyRunMessage("session-authority", "restricted turn", {
isInboundUserMessage: true,
toolAuthorityFingerprint: "authority-b",
}),
).resolves.toMatchObject({ status: "rejected", reason: "tool_authority_mismatch" });
expect(queueMessage).not.toHaveBeenCalled();
await expect(
queueCurrentReplyRunMessage("session-authority", "same authority", {
isInboundUserMessage: true,
toolAuthorityFingerprint: "authority-a",
}),
).resolves.toEqual({ status: "accepted" });
});
it("refuses stale injectable owners for admission and delivery until activity resumes", async () => {
vi.useFakeTimers();
try {
@@ -0,0 +1,79 @@
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import { resolveConversationCapabilityProfile } from "../../agents/conversation-capability-profile.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js";
import { readToolAllowlistIntersection } from "../../agents/tool-policy.js";
import type { FollowupRun } from "./queue.js";
/** Fingerprints the complete model-facing tool authority owned by one queued turn. */
export function resolveFollowupRunToolAuthorityFingerprint(
run: FollowupRun,
route?: { provider: string; model: string },
): string {
const execution = run.run;
const provider = route?.provider ?? execution.provider;
const model = route?.model ?? execution.model;
const policySessionKey = execution.runtimePolicySessionKey ?? execution.sessionKey;
const sandboxRuntime = resolveSandboxRuntimeStatus({
cfg: execution.config,
sessionKey: policySessionKey,
});
const capabilityProfile = resolveConversationCapabilityProfile({
config: execution.config,
sessionId: execution.sessionId,
sessionKey: policySessionKey,
runSessionKey: execution.sessionKey,
sandboxSessionKey: policySessionKey,
agentId: execution.agentId,
agentDir: execution.agentDir,
agentAccountId: execution.agentAccountId,
modelProvider: provider,
modelId: model,
messageProvider: execution.messageProvider,
messageChannel: run.originatingChannel,
chatType: execution.chatType,
conversationToolPolicy: execution.conversationToolPolicy,
groupId: execution.groupId,
groupChannel: execution.groupChannel,
groupSpace: execution.groupSpace,
memberRoleIds: execution.memberRoleIds,
spawnedBy: execution.spawnedBy,
senderId: execution.senderId,
senderName: execution.senderName,
senderUsername: execution.senderUsername,
senderE164: execution.senderE164,
senderIsOwner: execution.senderIsOwner,
workspaceDir: execution.workspaceDir,
cwd: execution.cwd,
sandboxToolPolicy: sandboxRuntime.sandboxed ? sandboxRuntime.toolPolicy : undefined,
inputProvenance: execution.inputProvenance,
trustedInternalHandoff: execution.trustedInternalHandoff,
scheduledToolPolicy: execution.scheduledToolPolicy,
runtimePluginToolGrant: execution.runtimePluginToolGrant,
});
return createHash("sha256")
.update(
stableStringify({
policy: capabilityProfile.policy,
toolsAllow: run.toolsAllow,
toolsAllowIntersection: run.toolsAllow
? readToolAllowlistIntersection(run.toolsAllow)
: undefined,
disableTools: run.disableTools === true,
sessionFile: execution.sessionFile,
agentDir: execution.agentDir,
workspaceDir: execution.workspaceDir,
cwd: execution.cwd,
toolOverrides: execution.toolOverrides,
execOverrides: execution.execOverrides,
elevatedLevel: execution.elevatedLevel,
bashElevated: execution.bashElevated,
traceAuthorized: execution.traceAuthorized === true,
approvalReviewerDeviceId: execution.approvalReviewerDeviceId,
authProfileId: execution.authProfileId,
clientCaps: [...new Set(execution.clientCaps ?? [])].toSorted(),
toolBindings: execution.toolBindings,
}),
)
.digest("hex");
}