diff --git a/extensions/codex/src/app-server/compact.test.ts b/extensions/codex/src/app-server/compact.test.ts index c945982d2b67..a8488d50338b 100644 --- a/extensions/codex/src/app-server/compact.test.ts +++ b/extensions/codex/src/app-server/compact.test.ts @@ -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); diff --git a/extensions/codex/src/app-server/compact.ts b/extensions/codex/src/app-server/compact.ts index 39015470f567..df79e672376c 100644 --- a/extensions/codex/src/app-server/compact.ts +++ b/extensions/codex/src/app-server/compact.ts @@ -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; diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index 59342b27cbe7..db92bb53c43e 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -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, diff --git a/extensions/codex/src/app-server/thread-lifecycle-io.ts b/extensions/codex/src/app-server/thread-lifecycle-io.ts index 8026cd893850..8897491745c7 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-io.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-io.ts @@ -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, diff --git a/extensions/codex/src/app-server/thread-lifecycle-run.ts b/extensions/codex/src/app-server/thread-lifecycle-run.ts index 9cf13f213851..852fbf96b6e2 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-run.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-run.ts @@ -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, diff --git a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts index e0c57655e923..939eba5245d8 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts @@ -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" } }, diff --git a/extensions/codex/src/app-server/thread-requests.ts b/extensions/codex/src/app-server/thread-requests.ts index c9e4399cdbea..0da2b0e331b1 100644 --- a/extensions/codex/src/app-server/thread-requests.ts +++ b/extensions/codex/src/app-server/thread-requests.ts @@ -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([ + ["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}`, ); diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index 58ca5597632c..8c1712e48bcb 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -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) { diff --git a/src/agents/cli-runner/execute-node-claude.ts b/src/agents/cli-runner/execute-node-claude.ts index 4d6c8cf9cdcc..5e18b684876c 100644 --- a/src/agents/cli-runner/execute-node-claude.ts +++ b/src/agents/cli-runner/execute-node-claude.ts @@ -201,6 +201,7 @@ export async function executeNodeClaudeRun(params: { ? { kind: "cli" as const, runId: contextParams.runId, + toolAuthorityFingerprint: contextParams.toolAuthorityFingerprint, cancel: abortNodeRun, } : undefined; diff --git a/src/agents/cli-runner/execute-process.ts b/src/agents/cli-runner/execute-process.ts index fb72e1d63ead..8e7c58ced9ae 100644 --- a/src/agents/cli-runner/execute-process.ts +++ b/src/agents/cli-runner/execute-process.ts @@ -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; diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index f1e0d3ddae41..a5add484a9c9 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -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; diff --git a/src/agents/embedded-agent-runner/compact.types.ts b/src/agents/embedded-agent-runner/compact.types.ts index 6fd17fbc1132..130361bc1904 100644 --- a/src/agents/embedded-agent-runner/compact.types.ts +++ b/src/agents/embedded-agent-runner/compact.types.ts @@ -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; diff --git a/src/agents/embedded-agent-runner/run-state.ts b/src/agents/embedded-agent-runner/run-state.ts index aefe3bb3647d..77003f7998b8 100644 --- a/src/agents/embedded-agent-runner/run-state.ts +++ b/src/agents/embedded-agent-runner/run-state.ts @@ -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, diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-stream-prepare.ts index c05c671156f7..568ea99bf8c3 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-prepare.ts @@ -426,6 +426,9 @@ export function prepareEmbeddedAttemptStream(input: { const queueHandle: AttemptStreamQueueHandle = { kind: "embedded", runId: attempt.runId, + ...(attempt.toolAuthorityFingerprint + ? { toolAuthorityFingerprint: attempt.toolAuthorityFingerprint } + : {}), queueMessage, messageInjection: { isAvailable: () => diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index a2164c44f0d1..eb4601903039 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -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. */ diff --git a/src/agents/embedded-agent-runner/runs.ts b/src/agents/embedded-agent-runner/runs.ts index 69792a02ad09..3b09cf5c5abc 100644 --- a/src/agents/embedded-agent-runner/runs.ts +++ b/src/agents/embedded-agent-runner/runs.ts @@ -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 { diff --git a/src/agents/harness/compaction.ts b/src/agents/harness/compaction.ts index 1b57229dc665..2d68ffb1c38c 100644 --- a/src/agents/harness/compaction.ts +++ b/src/agents/harness/compaction.ts @@ -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 = diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts index bfe8e0c49354..83080a11484b 100644 --- a/src/agents/harness/selection.ts +++ b/src/agents/harness/selection.ts @@ -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), diff --git a/src/auto-reply/reply/agent-runner-cli-candidate.ts b/src/auto-reply/reply/agent-runner-cli-candidate.ts index a43d735d0d3f..a12766254c4e 100644 --- a/src/auto-reply/reply/agent-runner-cli-candidate.ts +++ b/src/auto-reply/reply/agent-runner-cli-candidate.ts @@ -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, @@ -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, diff --git a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts index 477fb9bf26fd..a3d4e3379399 100644 --- a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts +++ b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts @@ -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(), diff --git a/src/auto-reply/reply/agent-runner-embedded-candidate.ts b/src/auto-reply/reply/agent-runner-embedded-candidate.ts index 7249b3185834..6d9153e39612 100644 --- a/src/auto-reply/reply/agent-runner-embedded-candidate.ts +++ b/src/auto-reply/reply/agent-runner-embedded-candidate.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner-execution.test-support.ts b/src/auto-reply/reply/agent-runner-execution.test-support.ts index 8abe2a0a7382..c1d32f00f98f 100644 --- a/src/auto-reply/reply/agent-runner-execution.test-support.ts +++ b/src/auto-reply/reply/agent-runner-execution.test-support.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index c5b170f7a421..9841d8d16b2c 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -95,6 +95,7 @@ function createReplyOperation(): TestReplyOperation { setPhase: vi.fn(), updateSessionId: vi.fn(), updateSessionKey: vi.fn(), + bindToolAuthorityFingerprint: vi.fn(), attachBackend: vi.fn(), detachBackend: vi.fn(), freezeAbort: vi.fn(), diff --git a/src/auto-reply/reply/agent-runner-run-params.ts b/src/auto-reply/reply/agent-runner-run-params.ts index 2f35c0803609..ff9981582dc0 100644 --- a/src/auto-reply/reply/agent-runner-run-params.ts +++ b/src/auto-reply/reply/agent-runner-run-params.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner-run.ts b/src/auto-reply/reply/agent-runner-run.ts index 783a11b72004..6226fa004e3d 100644 --- a/src/auto-reply/reply/agent-runner-run.ts +++ b/src/auto-reply/reply/agent-runner-run.ts @@ -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; diff --git a/src/auto-reply/reply/agent-runner-steer-adoption.ts b/src/auto-reply/reply/agent-runner-steer-adoption.ts index 2556b2d8a38f..dbf91c886cb8 100644 --- a/src/auto-reply/reply/agent-runner-steer-adoption.ts +++ b/src/auto-reply/reply/agent-runner-steer-adoption.ts @@ -38,6 +38,7 @@ type ActiveReplySteerParams = { touchActiveSessionEntry: () => Promise; 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 } : {}), diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 6bee2daa011c..13dbfdc75d41 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -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"); diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 60a4141a13c6..a94579c6f140 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -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; sourceTurnId?: string; runOverrides?: Partial; + 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({ diff --git a/src/auto-reply/reply/followup-turn-execution.ts b/src/auto-reply/reply/followup-turn-execution.ts index b5c3c92291f5..10f551d50600 100644 --- a/src/auto-reply/reply/followup-turn-execution.ts +++ b/src/auto-reply/reply/followup-turn-execution.ts @@ -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?.(); diff --git a/src/auto-reply/reply/get-reply-run-execute.ts b/src/auto-reply/reply/get-reply-run-execute.ts index b3eaaa878abf..8bf50b36c8e8 100644 --- a/src/auto-reply/reply/get-reply-run-execute.ts +++ b/src/auto-reply/reply/get-reply-run-execute.ts @@ -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), diff --git a/src/auto-reply/reply/queue.collect.test.ts b/src/auto-reply/reply/queue.collect.test.ts index 21d3dd12c763..ffb707f93629 100644 --- a/src/auto-reply/reply/queue.collect.test.ts +++ b/src/auto-reply/reply/queue.collect.test.ts @@ -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()}`, diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index da5a4b1f0872..c728eccd2e06 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -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 ?? "", diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 142527599f20..b6a3b0aa6a56 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -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; diff --git a/src/auto-reply/reply/reply-run-registry.contracts.ts b/src/auto-reply/reply/reply-run-registry.contracts.ts index 81c190eb9692..18e5e17935c4 100644 --- a/src/auto-reply/reply/reply-run-registry.contracts.ts +++ b/src/auto-reply/reply/reply-run-registry.contracts.ts @@ -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 diff --git a/src/auto-reply/reply/reply-run-registry.message-injection.ts b/src/auto-reply/reply/reply-run-registry.message-injection.ts index 6f6f7b20426d..0ff9ac0f9dc4 100644 --- a/src/auto-reply/reply/reply-run-registry.message-injection.ts +++ b/src/auto-reply/reply/reply-run-registry.message-injection.ts @@ -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 }; } diff --git a/src/auto-reply/reply/reply-run-registry.operation.ts b/src/auto-reply/reply/reply-run-registry.operation.ts index 2e3ff805b6ff..7d2acf565cae 100644 --- a/src/auto-reply/reply/reply-run-registry.operation.ts +++ b/src/auto-reply/reply/reply-run-registry.operation.ts @@ -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"); diff --git a/src/auto-reply/reply/reply-run-registry.test.ts b/src/auto-reply/reply/reply-run-registry.test.ts index 3dcb24728048..d77856237657 100644 --- a/src/auto-reply/reply/reply-run-registry.test.ts +++ b/src/auto-reply/reply/reply-run-registry.test.ts @@ -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(run: () => Promise): Promise { } 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 { diff --git a/src/auto-reply/reply/reply-tool-authority.ts b/src/auto-reply/reply/reply-tool-authority.ts new file mode 100644 index 000000000000..29a4f9567b28 --- /dev/null +++ b/src/auto-reply/reply/reply-tool-authority.ts @@ -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"); +}