mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix: enforce native tool policy across harness lifecycles
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user