fix(cron): bind scheduled authority to creator accounts

This commit is contained in:
joshavant
2026-07-23 16:39:21 -05:00
committed by Josh Avant
parent 24088edcb5
commit 5d21ba0a2a
49 changed files with 525 additions and 41 deletions

View File

@@ -460,7 +460,10 @@ describe("Codex app-server dynamic tool build", () => {
sourceTool: "subagent_announce",
};
params.trustedInternalHandoff = true;
params.scheduledToolPolicy = { ownerSessionKey: "agent:main:discord:group:ops" };
params.scheduledToolPolicy = {
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
};
let receivedOptions: unknown;
setOpenClawCodingToolsFactoryForTests((options) => {
receivedOptions = options;

View File

@@ -727,7 +727,10 @@ describe("createCopilotToolBridge", () => {
jobId: "job-1",
memoryFlushWritePath: ".memory/append.md",
toolsAllow: ["read", "edit"],
scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" },
scheduledToolPolicy: {
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
},
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
@@ -745,6 +748,7 @@ describe("createCopilotToolBridge", () => {
expect(opts.runtimeToolAllowlist).toEqual(["read", "edit"]);
expect(opts.scheduledToolPolicy).toEqual({
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
});
});

View File

@@ -325,6 +325,7 @@ import {
WorkerTranscriptCommitResultSchema,
WorkerTranscriptMessageSchema,
WORKER_HEARTBEAT_INTERVAL_MS,
WORKER_LAUNCH_V2_PROTOCOL_FEATURE,
WORKER_LIVE_EVENT_PROTOCOL_FEATURE,
WORKER_PROTOCOL_FEATURES,
WORKER_PROTOCOL_MAX_FEATURE_LENGTH,
@@ -1070,6 +1071,7 @@ export {
WorkerTranscriptCommitResultSchema,
WorkerTranscriptMessageSchema,
WORKER_HEARTBEAT_INTERVAL_MS,
WORKER_LAUNCH_V2_PROTOCOL_FEATURE,
WORKER_LIVE_EVENT_PROTOCOL_FEATURE,
WORKER_PROTOCOL_FEATURES,
WORKER_PROTOCOL_MAX_FEATURE_LENGTH,

View File

@@ -130,6 +130,7 @@ const CronDisplayNameSchema = Type.String({ minLength: 1, maxLength: 200, patter
const CronOwnerSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
sessionKey: Type.Optional(NonEmptyString),
accountId: Type.Optional(NonEmptyString),
});
const CronAnnounceChannelSchema = Type.Union([Type.Literal("last"), NonBlankString]);
const CronFailoverReasonSchema = Type.Union([

View File

@@ -11,6 +11,7 @@ import {
WorkerProtocolCloseReasonSchema,
WorkerTranscriptCommitRequestFrameSchema,
WorkerTranscriptCommitResponseFrameSchema,
WORKER_LAUNCH_V2_PROTOCOL_FEATURE,
WORKER_PROTOCOL_FEATURES,
WORKER_RPC_SET_VERSION,
WORKER_TRANSCRIPT_MAX_JSON_DEPTH,
@@ -296,6 +297,7 @@ describe("worker protocol schemas", () => {
it("validates the additive live-event protocol", () => {
expect(WORKER_RPC_SET_VERSION).toBe(1);
expect(WORKER_PROTOCOL_FEATURES).toContain("worker-live-event-v1");
expect(WORKER_PROTOCOL_FEATURES).toContain(WORKER_LAUNCH_V2_PROTOCOL_FEATURE);
for (const validEvent of [
assistant,
event("thinking", { text: "x", delta: "x" }),

View File

@@ -35,10 +35,12 @@ export const WORKER_PROTOCOL_METHODS = [
] as const;
export const WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE = "worker-transcript-commit-v1";
export const WORKER_LIVE_EVENT_PROTOCOL_FEATURE = "worker-live-event-v1";
export const WORKER_LAUNCH_V2_PROTOCOL_FEATURE = "worker-launch-v2";
export const WORKER_PROTOCOL_FEATURES = [
"worker-heartbeat-v1",
WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE,
WORKER_LIVE_EVENT_PROTOCOL_FEATURE,
WORKER_LAUNCH_V2_PROTOCOL_FEATURE,
"worker-inference-v1",
] as const;
export const WORKER_PROTOCOL_MAX_METHOD_LENGTH = 64;

View File

@@ -30,6 +30,20 @@ vi.mock("../channels/plugins/session-conversation.js", () => ({
}),
}));
vi.mock("../channels/plugins/index.js", () => ({
getLoadedChannelPlugin: () => ({
config: {
listAccountIds: (config: OpenClawConfig) => [
"default",
...Object.keys(
(config.channels?.whatsapp as { accounts?: Record<string, unknown> } | undefined)
?.accounts ?? {},
),
],
},
}),
}));
async function writeSessionEntries(
storePath: string,
entries: Record<string, unknown>,
@@ -206,6 +220,38 @@ describe("resolveGroupToolPolicy group context validation", () => {
expect(policy).toEqual({ allow: ["read"] });
});
it("fails closed when scheduled authority names a removed account", () => {
expect(
resolveGroupToolPolicy({
config: cfg,
sessionKey: "agent:main:whatsapp:group:safe-room",
accountId: "removed",
requireConfiguredAccount: true,
}),
).toEqual({ allow: [] });
});
it("resolves scheduled group policy for a still-configured named account", () => {
const accountCfg = {
...cfg,
channels: {
whatsapp: {
...cfg.channels?.whatsapp,
accounts: { work: {} },
},
},
} as OpenClawConfig;
expect(
resolveGroupToolPolicy({
config: accountCfg,
sessionKey: "agent:main:whatsapp:group:safe-room",
accountId: "work",
requireConfiguredAccount: true,
}),
).toEqual({ allow: ["read"] });
});
});
describe("resolveSubagentToolPolicyForSession", () => {

View File

@@ -14,6 +14,7 @@ import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { AgentToolsConfig } from "../config/types.tools.js";
import { logWarn } from "../logger.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/account-id.js";
import { normalizeAgentId } from "../routing/session-key.js";
import {
parseRawSessionConversationRef,
@@ -466,6 +467,8 @@ export function resolveGroupToolPolicy(params: {
groupChannel?: string | null;
groupSpace?: string | null;
accountId?: string | null;
/** Scheduled authority must not fall back from a removed named account. */
requireConfiguredAccount?: boolean;
senderPolicyMode?: "always" | "never";
senderId?: string | null;
senderName?: string | null;
@@ -489,13 +492,13 @@ export function resolveGroupToolPolicy(params: {
...(spawnedContext.groupIds ?? []),
...buildScopedGroupIdCandidates(trustedGroup.groupId),
]);
if (groupIds.length === 0) {
return undefined;
}
const channelRaw = sessionContext.channel ?? spawnedContext.channel ?? params.messageProvider;
const channel = normalizeMessageChannel(channelRaw);
const accountId = normalizeAccountId(params.accountId);
if (!channel) {
return undefined;
return params.requireConfiguredAccount && accountId !== DEFAULT_ACCOUNT_ID
? { allow: [] }
: undefined;
}
let plugin;
try {
@@ -503,13 +506,32 @@ export function resolveGroupToolPolicy(params: {
} catch {
plugin = undefined;
}
if (params.requireConfiguredAccount && accountId !== DEFAULT_ACCOUNT_ID) {
let configured: boolean;
try {
configured =
plugin?.config
.listAccountIds(params.config)
.some((candidate) => normalizeAccountId(candidate) === accountId) === true;
} catch {
configured = false;
}
if (!configured) {
// A named creator account is an authority boundary, not a fallback hint.
// If it disappears, deny the scheduled surface instead of selecting default config.
return { allow: [] };
}
}
if (groupIds.length === 0) {
return undefined;
}
for (const groupId of groupIds) {
const toolsConfig = plugin?.groups?.resolveToolPolicy?.({
cfg: params.config,
groupId,
groupChannel: trustedGroup.dropped ? null : params.groupChannel,
groupSpace: trustedGroup.dropped ? null : params.groupSpace,
accountId: params.accountId,
accountId,
senderPolicyMode: params.senderPolicyMode,
senderId: params.senderId,
senderName: params.senderName,
@@ -527,7 +549,7 @@ export function resolveGroupToolPolicy(params: {
messageProvider: channel,
groupId: groupIds[0],
groupIdCandidates: groupIds.slice(1),
accountId: params.accountId,
accountId,
senderPolicyMode: params.senderPolicyMode,
senderId: params.senderId,
senderName: params.senderName,

View File

@@ -2434,7 +2434,10 @@ describe("prepareCliRunContext", () => {
provider: "native-cli",
runId: "run-test-loopback-prompt-tools",
config: createCliBackendConfig({ bundleMcp: true }),
scheduledToolPolicy: { ownerSessionKey: "agent:worker:discord:group:ops" },
scheduledToolPolicy: {
ownerSessionKey: "agent:worker:discord:group:ops",
ownerAccountId: "default",
},
cliSessionBinding: {
sessionId: "cli-session",
promptToolNamesHash: "old-tool-surface",
@@ -2461,7 +2464,10 @@ describe("prepareCliRunContext", () => {
workspaceDir: expect.any(String),
modelProvider: "native-cli",
modelId: "test-model",
scheduledToolPolicy: { ownerSessionKey: "agent:worker:discord:group:ops" },
scheduledToolPolicy: {
ownerSessionKey: "agent:worker:discord:group:ops",
ownerAccountId: "default",
},
});
expect(context.systemPrompt).toContain("## Memory Recall");
expect(context.systemPrompt).toContain("tools=memory_search");
@@ -2982,7 +2988,10 @@ describe("prepareCliRunContext", () => {
sessionKey: "agent:main:main",
provider: "claude-cli",
toolsAllow: ["write"],
scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" },
scheduledToolPolicy: {
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
},
});
cleanup = context.preparedBackend.cleanup;
@@ -2997,11 +3006,15 @@ describe("prepareCliRunContext", () => {
]);
expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.scheduledToolPolicy).toEqual({
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
});
expect(resolveMcpLoopbackPolicyTools).toHaveBeenCalledWith(
expect.objectContaining({
toolsAllow: ["write"],
scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" },
scheduledToolPolicy: {
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
},
}),
);
const projected = resolveMcpLoopbackPolicyTools.mock.calls[0]?.[0];

View File

@@ -196,7 +196,10 @@ describe("resolveConversationCapabilityProfile", () => {
const legacy = resolveConversationCapabilityProfile(baseParams);
const scheduled = resolveConversationCapabilityProfile({
...baseParams,
scheduledToolPolicy: { ownerSessionKey: "agent:main:whatsapp:group:team" },
scheduledToolPolicy: {
ownerSessionKey: "agent:main:whatsapp:group:team",
ownerAccountId: "default",
},
});
expect(legacy.policy.senderPolicy).toEqual({ deny: ["write"] });

View File

@@ -222,7 +222,7 @@ export function resolveConversationCapabilityProfile(
groupId: trustedGroup.groupId,
groupChannel: trustedGroupChannel,
groupSpace: trustedGroupSpace,
accountId: params.agentAccountId,
accountId: params.scheduledToolPolicy?.ownerAccountId ?? params.agentAccountId,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
@@ -231,6 +231,7 @@ export function resolveConversationCapabilityProfile(
trustedInternalHandoff: params.trustedInternalHandoff,
senderPolicyMode: params.scheduledToolPolicy || isOwnerInternalSession ? "never" : "always",
groupPolicySessionKey: params.scheduledToolPolicy?.ownerSessionKey,
requireConfiguredGroupAccount: Boolean(params.scheduledToolPolicy),
});
const { groupPolicy, senderPolicy, subagentPolicy, inheritedToolPolicy } = requesterPolicies;
const profilePolicy = resolveToolProfilePolicy(effective.profile);

View File

@@ -693,7 +693,8 @@ function resolvePluginHarnessToolPolicies(
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
accountId: params.agentAccountId,
accountId: params.scheduledToolPolicy?.ownerAccountId ?? params.agentAccountId,
requireConfiguredAccount: Boolean(params.scheduledToolPolicy),
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,

View File

@@ -75,4 +75,29 @@ describe("createOpenClawTools Gateway caller identity", () => {
},
]);
});
it("uses scheduled creator account authority without changing live delivery routing", async () => {
mocks.observedIdentities.length = 0;
const tool = requireTool("synthetic_direct_cron_plugin", {
agentChannel: "discord",
agentTo: "channel:123",
agentAccountId: "delivery-account",
scheduledToolPolicy: {
ownerSessionKey: "agent:main:discord:channel:creator",
ownerAccountId: "creator-account",
},
});
await tool.execute("tool-call-scheduled", {});
expect(mocks.observedIdentities).toEqual([
{
agentId: "main",
sessionKey: "agent:main:discord:channel:123",
turnSourceChannel: "discord",
turnSourceTo: "channel:123",
turnSourceAccountId: "creator-account",
},
]);
});
});

View File

@@ -262,6 +262,10 @@ export function createOpenClawTools(
accountId: options?.agentAccountId,
threadId: options?.agentThreadId,
});
// Scheduled turns keep delivery routing live, but Gateway authorization remains bound to the
// authenticated creator account captured in the immutable scheduled authority envelope.
const gatewayCallerAccountId =
options?.scheduledToolPolicy?.ownerAccountId ?? options?.agentAccountId;
const runtimeWebTools = getActiveRuntimeWebToolsMetadata();
const sandbox =
options?.sandboxRoot && options?.sandboxFsBridge
@@ -495,6 +499,7 @@ export function createOpenClawTools(
]),
createCronTool({
agentSessionKey: options?.agentSessionKey,
agentAccountId: gatewayCallerAccountId,
currentDeliveryContext: {
channel: options?.agentChannel,
to: options?.currentChannelId ?? options?.agentTo,
@@ -743,7 +748,10 @@ export function createOpenClawTools(
options?.recordToolPrepStage?.("openclaw-tools:client-capabilities");
const hookAgentId = options?.requesterAgentIdOverride ?? sessionAgentId;
const wrapGatewayCallerIdentity = createGatewayToolCallerWrapper(hookAgentId, options);
const wrapGatewayCallerIdentity = createGatewayToolCallerWrapper(
hookAgentId,
options ? { ...options, agentAccountId: gatewayCallerAccountId } : options,
);
if (options?.wrapBeforeToolCallHook === false) {
return allTools.map(wrapGatewayCallerIdentity);

View File

@@ -62,6 +62,8 @@ type RequesterToolPolicyParams = {
senderPolicyMode?: SenderPolicyMode;
/** Group session selected by a trusted scheduled authority envelope. */
groupPolicySessionKey?: string;
/** Fail closed when scheduled authority names a removed non-default account. */
requireConfiguredGroupAccount?: boolean;
};
function policyFromEnvelope(
@@ -194,6 +196,7 @@ export function resolveRequesterToolPolicies(
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
accountId: params.accountId,
requireConfiguredAccount: params.requireConfiguredGroupAccount,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { resolveScheduledToolPolicyContext } from "./scheduled-tool-policy.js";
describe("resolveScheduledToolPolicyContext", () => {
it("requires both a persisted cap and a trusted owner session", () => {
it("requires a persisted cap and a trusted owner session/account pair", () => {
expect(
resolveScheduledToolPolicyContext({
ownerSessionKey: "agent:main:discord:group:ops",
@@ -17,6 +17,13 @@ describe("resolveScheduledToolPolicyContext", () => {
resolveScheduledToolPolicyContext({
toolsAllow: ["write"],
ownerSessionKey: " ",
ownerAccountId: "work",
}),
).toBeUndefined();
expect(
resolveScheduledToolPolicyContext({
toolsAllow: ["write"],
ownerSessionKey: "agent:main:discord:group:ops",
}),
).toBeUndefined();
});
@@ -26,7 +33,11 @@ describe("resolveScheduledToolPolicyContext", () => {
resolveScheduledToolPolicyContext({
toolsAllow: [],
ownerSessionKey: " agent:main:discord:group:ops ",
ownerAccountId: " work ",
}),
).toEqual({ ownerSessionKey: "agent:main:discord:group:ops" });
).toEqual({
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
});
});
});

View File

@@ -5,16 +5,21 @@
*/
export type ScheduledToolPolicyContext = {
ownerSessionKey: string;
ownerAccountId: string;
};
/** Builds scheduled policy context only when both the cap and trusted owner exist. */
export function resolveScheduledToolPolicyContext(params: {
toolsAllow?: readonly string[];
ownerSessionKey?: string | null;
ownerAccountId?: string | null;
}): ScheduledToolPolicyContext | undefined {
if (params.toolsAllow === undefined) {
return undefined;
}
const ownerSessionKey = params.ownerSessionKey?.trim();
return ownerSessionKey ? { ownerSessionKey } : undefined;
const ownerAccountId = params.ownerAccountId?.trim();
// Accountless jobs predate creator-account authority. Keep their shipped sender-policy path;
// inferring authority from a later delivery account would cross the account boundary.
return ownerSessionKey && ownerAccountId ? { ownerSessionKey, ownerAccountId } : undefined;
}

View File

@@ -22,6 +22,7 @@ vi.mock("../../config/sessions/delivery-info.js", () => ({
import { GatewayClientRequestError } from "../../gateway/client.js";
import { buildAgentPeerSessionKey } from "../../routing/session-key.js";
import { createCronTool } from "./cron-tool.js";
import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js";
describe("cron tool", () => {
type SchemaLike = {
@@ -1551,6 +1552,31 @@ describe("cron tool", () => {
expect(sessionKey).toBe(callerSessionKey);
});
it("forwards authenticated source account separately from delivery account", async () => {
let identity: ReturnType<typeof getGatewayToolCallerIdentity> = undefined;
const tool = createCronTool(
{
agentSessionKey: "agent:main:discord:channel:ops",
agentAccountId: "source-account",
currentDeliveryContext: { accountId: "delivery-account" },
},
{
callGatewayTool: async <T>() => {
identity = getGatewayToolCallerIdentity();
return { enabled: true, jobs: 0 } as T;
},
},
);
await tool.execute("call-source-account", { action: "status" });
expect(identity).toMatchObject({
agentId: "main",
sessionKey: "agent:main:discord:channel:ops",
turnSourceAccountId: "source-account",
});
});
it("preserves explicit job.sessionKey on add", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });

View File

@@ -792,7 +792,11 @@ Restricted isolated runs may only self status/list, current get/runs/remove, and
const callerScope = resolveCronToolCallerScope(opts, runtimeConfig);
const callerIdentity =
callerScope && opts?.agentSessionKey?.trim()
? { agentId: callerScope.agentId, sessionKey: opts.agentSessionKey.trim() }
? {
agentId: callerScope.agentId,
sessionKey: opts.agentSessionKey.trim(),
turnSourceAccountId: opts.agentAccountId,
}
: undefined;
return await withGatewayToolCallerIdentity(callerIdentity, async () => {

View File

@@ -11,6 +11,8 @@ export type CronCreatorToolAllowlistEntry =
export type CronToolOptions = {
agentSessionKey?: string;
/** Authenticated source account; authority must not be inferred from delivery. */
agentAccountId?: string;
currentDeliveryContext?: DeliveryContext;
/**
* Effective tool surface visible to the caller that created or edited a cron job.

View File

@@ -69,7 +69,8 @@ export function resolveWebSearchToolPolicy(
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
accountId: params.agentAccountId,
accountId: params.scheduledToolPolicy?.ownerAccountId ?? params.agentAccountId,
requireConfiguredAccount: Boolean(params.scheduledToolPolicy),
senderPolicyMode: params.scheduledToolPolicy ? ("never" as const) : ("always" as const),
};
const senderPolicyParams = {
@@ -88,6 +89,7 @@ export function resolveWebSearchToolPolicy(
trustedInternalHandoff: params.trustedInternalHandoff,
senderPolicyMode: params.scheduledToolPolicy ? "never" : "always",
groupPolicySessionKey: params.scheduledToolPolicy?.ownerSessionKey,
requireConfiguredGroupAccount: Boolean(params.scheduledToolPolicy),
});
const persistentGroupPolicy = requesterPolicies.delegated
? undefined

View File

@@ -388,6 +388,8 @@ export type SessionEntry = SessionRestartRecoveryState &
toolsAllowIsDefault?: boolean;
/** Server-stamped creator session used to resolve sender-independent group policy. */
ownerSessionKey?: string;
/** Server-stamped creator account paired with ownerSessionKey. */
ownerAccountId?: string;
cliSessionBindingFacts?: {
extraSystemPromptStatic?: string;
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";

View File

@@ -277,6 +277,7 @@ function createCronPromptExecutor(params: {
const scheduledToolPolicy = resolveScheduledToolPolicyContext({
toolsAllow: params.agentPayload?.toolsAllow,
ownerSessionKey: params.job.owner?.sessionKey,
ownerAccountId: params.job.owner?.accountId,
});
if (!params.sourceDelivery) {
logWarn(

View File

@@ -146,6 +146,7 @@ describe("createPersistCronSessionEntry", () => {
toolsAllow: ["image_generate", "write"],
toolsAllowIsDefault: true,
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
persistSessionEntry,
});
@@ -172,6 +173,7 @@ describe("createPersistCronSessionEntry", () => {
toolsAllow: ["image_generate", "write"],
toolsAllowIsDefault: true,
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
},
});

View File

@@ -204,6 +204,7 @@ export function createCronRunContinuationSession(params: {
toolsAllow?: string[];
toolsAllowIsDefault?: boolean;
ownerSessionKey?: string;
ownerAccountId?: string;
cliSessionBindingFacts?: {
extraSystemPromptStatic?: string;
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
@@ -216,8 +217,13 @@ export function createCronRunContinuationSession(params: {
phase: "running" as const,
...(params.toolsAllow !== undefined ? { toolsAllow: [...params.toolsAllow] } : {}),
...(params.toolsAllowIsDefault === true ? { toolsAllowIsDefault: true } : {}),
...(params.toolsAllow !== undefined && params.ownerSessionKey?.trim()
? { ownerSessionKey: params.ownerSessionKey.trim() }
...(params.toolsAllow !== undefined &&
params.ownerSessionKey?.trim() &&
params.ownerAccountId?.trim()
? {
ownerSessionKey: params.ownerSessionKey.trim(),
ownerAccountId: params.ownerAccountId.trim(),
}
: {}),
...(params.cliSessionBindingFacts
? { cliSessionBindingFacts: { ...params.cliSessionBindingFacts } }

View File

@@ -34,7 +34,11 @@ function makeParams() {
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "check allowed tools" },
delivery: { mode: "none" },
owner: { agentId: "main", sessionKey: "agent:main:whatsapp:group:team" },
owner: {
agentId: "main",
sessionKey: "agent:main:whatsapp:group:team",
accountId: "default",
},
} as never,
message: "check allowed tools",
sessionKey: "cron:tools-allow",
@@ -77,13 +81,13 @@ function makeParamsWithDefaultToolsAllow(toolsAllow: string[]) {
function requireEmbeddedAgentCall(): {
jobId?: string;
toolsAllow?: string[];
scheduledToolPolicy?: { ownerSessionKey: string };
scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string };
} {
const call = runEmbeddedAgentMock.mock.calls[0]?.[0] as
| {
jobId?: string;
toolsAllow?: string[];
scheduledToolPolicy?: { ownerSessionKey: string };
scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string };
}
| undefined;
if (!call) {
@@ -134,6 +138,21 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => {
},
);
it(
"keeps capped accountless legacy jobs on the ordinary sender-policy path",
{ timeout: RUN_TOOLS_ALLOW_TIMEOUT_MS },
async () => {
const params = makeParamsWithToolsAllow(["cron"]);
delete (params.job as { owner?: { accountId?: string } }).owner?.accountId;
await runCronIsolatedAgentTurn(params);
const call = requireEmbeddedAgentCall();
expect(call.toolsAllow).toEqual(["cron"]);
expect(call.scheduledToolPolicy).toBeUndefined();
},
);
it(
"passes through isolated cron toolsAllow=cron self-removal path",
{ timeout: RUN_TOOLS_ALLOW_TIMEOUT_MS },
@@ -146,6 +165,7 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => {
expect(call.toolsAllow).toEqual(["cron"]);
expect(call.scheduledToolPolicy).toEqual({
ownerSessionKey: "agent:main:whatsapp:group:team",
ownerAccountId: "default",
});
},
);

View File

@@ -1127,6 +1127,7 @@ async function prepareCronRunContext(params: {
toolsAllow: agentPayload?.toolsAllow,
toolsAllowIsDefault: agentPayload?.toolsAllowIsDefault,
ownerSessionKey: input.job.owner?.sessionKey,
ownerAccountId: input.job.owner?.accountId,
cliSessionBindingFacts: {
sourceReplyDeliveryMode: sourceDelivery.sourceReplyDeliveryMode,
requireExplicitMessageTarget: sourceDelivery.messageTool.requireExplicitTarget,

View File

@@ -6,6 +6,7 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { normalizeOptionalAccountId } from "../routing/account-id.js";
import { sanitizeAgentId } from "../routing/session-key.js";
import { isRecord } from "../utils.js";
import { shouldDefaultCronDeliveryToAnnounce } from "./delivery-defaults.js";
@@ -370,10 +371,14 @@ export function normalizeCronJobInput(
if (isRecord(base.owner)) {
const agentId = normalizeOptionalString(base.owner.agentId);
const sessionKey = normalizeOptionalString(base.owner.sessionKey);
if (agentId || sessionKey) {
const accountId = normalizeOptionalAccountId(
typeof base.owner.accountId === "string" ? base.owner.accountId : undefined,
);
if (agentId || sessionKey || accountId) {
next.owner = {
...(agentId ? { agentId: sanitizeAgentId(agentId) } : {}),
...(sessionKey ? { sessionKey } : {}),
...(accountId ? { accountId } : {}),
};
} else {
delete next.owner;

View File

@@ -7,6 +7,7 @@ import {
} from "@openclaw/normalization-core/string-coerce";
import { resolveCronTriggerMinIntervalMs } from "../../config/cron-limits.js";
import type { CronConfig } from "../../config/types.cron.js";
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { compileSafeRegexDetailed } from "../../security/safe-regex.js";
import { isCronJobActive } from "../active-jobs.js";
@@ -1097,15 +1098,17 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
}
const ownerAgentId = normalizeOptionalAgentId(input.owner?.agentId);
const ownerSessionKey = normalizeOptionalString(input.owner?.sessionKey);
const ownerAccountId = normalizeOptionalAccountId(input.owner?.accountId);
const job: CronJob = {
id,
...(declarationKey ? { declarationKey } : {}),
...(displayName ? { displayName } : {}),
...(ownerAgentId || ownerSessionKey
...(ownerAgentId || ownerSessionKey || ownerAccountId
? {
owner: {
...(ownerAgentId ? { agentId: ownerAgentId } : {}),
...(ownerSessionKey ? { sessionKey: ownerSessionKey } : {}),
...(ownerAccountId ? { accountId: ownerAccountId } : {}),
},
}
: {}),

View File

@@ -11,6 +11,24 @@ function roundTrip(schedule: CronSchedule): CronSchedule | null {
}
describe("schedule column codec round-trip", () => {
it("round-trips the creator account through the additive job_json envelope", () => {
const job = projectCronJobThroughStorageCodec(
makeCronJob({
owner: {
agentId: "main",
sessionKey: "agent:main:discord:group:ops",
accountId: "work",
},
}),
);
expect(job.owner).toEqual({
agentId: "main",
sessionKey: "agent:main:discord:group:ops",
accountId: "work",
});
});
it("round-trips pacing through the additive job_json envelope", () => {
const job = projectCronJobThroughStorageCodec(
makeCronJob({ pacing: { min: "15m", max: "4h" } }),

View File

@@ -2,6 +2,7 @@
import type { DatabaseSync } from "node:sqlite";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { executeSqliteQuerySync } from "../../infra/kysely-sync.js";
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
import { normalizeCronJobIdentityFields } from "../normalize-job-identity.js";
import { normalizeCronJobInput } from "../normalize.js";
import { getInvalidPersistedCronJobReason } from "../persisted-shape.js";
@@ -271,6 +272,11 @@ function pacingFromRow(row: CronJobRow): CronPacing | undefined {
}
function rowToCronJob(row: CronJobRow): CronJob | null {
const jobJson = parseJsonObject<Record<string, unknown>>(row.job_json, {});
const jsonOwner = isRecord(jobJson.owner) ? jobJson.owner : undefined;
const ownerAccountId = normalizeOptionalAccountId(
typeof jsonOwner?.accountId === "string" ? jsonOwner.accountId : undefined,
);
const schedule = scheduleFromRow(row);
const payload = payloadFromRow(row);
const delivery = deliveryFromRow(row);
@@ -285,11 +291,12 @@ function rowToCronJob(row: CronJobRow): CronJob | null {
id: row.job_id,
...(row.declaration_key ? { declarationKey: row.declaration_key } : {}),
...(row.display_name ? { displayName: row.display_name } : {}),
...(row.owner_agent_id || row.owner_session_key
...(row.owner_agent_id || row.owner_session_key || ownerAccountId
? {
owner: {
...(row.owner_agent_id ? { agentId: row.owner_agent_id } : {}),
...(row.owner_session_key ? { sessionKey: row.owner_session_key } : {}),
...(ownerAccountId ? { accountId: ownerAccountId } : {}),
},
}
: {}),

View File

@@ -311,13 +311,17 @@ describe("cron trigger script evaluator", () => {
const runHeadless = vi.fn(async () => completed({ value: { fire: false } }));
const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless });
for (const ownerSessionKey of ["agent:main:discord:group:a", "agent:main:discord:group:b"]) {
for (const [ownerSessionKey, ownerAccountId] of [
["agent:main:discord:group:a", "alpha"],
["agent:main:discord:group:b", "beta"],
] as const) {
await evaluate({
jobId: "job-owner-session",
script: "return result",
state: null,
toolsAllow: ["write"],
ownerSessionKey,
ownerAccountId,
});
}
@@ -325,6 +329,10 @@ describe("cron trigger script evaluator", () => {
"agent:main:discord:group:a",
"agent:main:discord:group:b",
]);
expect(prepareRuntime.mock.calls.map(([params]) => params.ownerAccountId)).toEqual([
"alpha",
"beta",
]);
});
it.each([

View File

@@ -83,6 +83,7 @@ type PrepareTriggerRuntime = (params: {
agentId?: string;
toolsAllow?: string[];
ownerSessionKey?: string;
ownerAccountId?: string;
signal?: AbortSignal;
}) => Promise<PreparedTriggerRuntime>;
@@ -109,6 +110,7 @@ async function prepareTriggerRuntime(params: {
agentId?: string;
toolsAllow?: string[];
ownerSessionKey?: string;
ownerAccountId?: string;
signal?: AbortSignal;
}): Promise<PreparedTriggerRuntime> {
params.signal?.throwIfAborted();
@@ -179,6 +181,7 @@ async function prepareTriggerRuntime(params: {
scheduledToolPolicy: resolveScheduledToolPolicyContext({
toolsAllow: params.toolsAllow,
ownerSessionKey: params.ownerSessionKey,
ownerAccountId: params.ownerAccountId,
}),
toolConstructionPlan: toolPlan.codingToolConstructionPlan,
})
@@ -349,6 +352,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
agentId: string;
toolsAllow?: string[];
ownerSessionKey?: string;
ownerAccountId?: string;
toolsAllowKey: string;
signal: AbortSignal;
}): Promise<PreparedTriggerRuntime> => {
@@ -384,6 +388,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
agentId: request.requestedAgentId,
toolsAllow: request.toolsAllow,
ownerSessionKey: request.ownerSessionKey,
ownerAccountId: request.ownerAccountId,
signal: request.signal,
});
const entry: TriggerRuntimeCacheEntry = {
@@ -410,6 +415,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
script: string;
toolsAllow?: string[];
ownerSessionKey?: string;
ownerAccountId?: string;
abortSignal?: AbortSignal;
wallClockMs: number;
maxToolCalls: number;
@@ -430,6 +436,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
const toolsAllowKey = JSON.stringify([
params.toolsAllow ?? null,
params.ownerSessionKey?.trim() || null,
params.ownerAccountId?.trim() || null,
]);
const runtime = await resolveCachedRuntime({
runtimeConfig,
@@ -438,6 +445,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
agentId,
toolsAllow: params.toolsAllow,
ownerSessionKey: params.ownerSessionKey,
ownerAccountId: params.ownerAccountId,
toolsAllowKey,
signal: evaluationScope.signal,
});
@@ -605,6 +613,7 @@ export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) {
streamBatch?: string;
toolsAllow?: string[];
ownerSessionKey?: string;
ownerAccountId?: string;
abortSignal?: AbortSignal;
}): Promise<CronTriggerEvaluationResult> => {
if (activeTriggerEvaluations >= MAX_CONCURRENT_TRIGGER_EVALS) {
@@ -632,6 +641,7 @@ export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) {
streamBatch?: string;
toolsAllow?: string[];
ownerSessionKey?: string;
ownerAccountId?: string;
timeoutSeconds?: number;
toolBudget?: number;
abortSignal?: AbortSignal;

View File

@@ -463,6 +463,8 @@ export type CronJob = CronJobBase<
owner?: {
agentId?: string;
sessionKey?: string;
/** Authenticated account that created this scheduled authority envelope. */
accountId?: string;
};
trigger?: CronTrigger;
state: CronJobState;

View File

@@ -66,6 +66,23 @@ describe("agent runtime identity token", () => {
});
});
it("round-trips the authenticated turn-source account", async () => {
useTempHome();
const runtimeToken = await importRuntimeTokenModule();
const token = await runtimeToken.mintAgentRuntimeIdentityToken({
agentId: "main",
sessionKey: "session-1",
turnSourceAccountId: " Work ",
});
await expect(runtimeToken.verifyAgentRuntimeIdentityToken(token)).resolves.toEqual({
kind: "agentRuntime",
agentId: "main",
sessionKey: "session-1",
turnSourceAccountId: "work",
});
});
it("does not mint local credentials while rejecting invalid presented tokens", async () => {
const home = useTempHome();
const runtimeToken = await importRuntimeTokenModule();

View File

@@ -6,6 +6,7 @@ import { normalizeChatType } from "../channels/chat-type.js";
import type { ChannelId } from "../channels/plugins/types.public.js";
import type { InternalChannelThreadingToolContext } from "../channels/threading-tool-context-internal.js";
import { ensureExecApprovalsSnapshot, loadExecApprovalsAsync } from "../infra/exec-approvals.js";
import { normalizeOptionalAccountId } from "../routing/account-id.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import type { AgentRuntimeMessageActionContext } from "./message-action-turn-capability.js";
@@ -18,6 +19,7 @@ export type AgentRuntimeIdentity = {
kind: "agentRuntime";
agentId: string;
sessionKey: string;
turnSourceAccountId?: string;
messageActionContext?: AgentRuntimeMessageActionContext;
};
@@ -25,6 +27,7 @@ type AgentRuntimeIdentityTokenPayload = {
kind: typeof AGENT_RUNTIME_IDENTITY_TOKEN_KIND;
agentId: string;
sessionKey: string;
turnSourceAccountId?: string;
messageActionContext?: AgentRuntimeMessageActionContext;
};
@@ -160,6 +163,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
kind?: unknown;
agentId?: unknown;
sessionKey?: unknown;
turnSourceAccountId?: unknown;
messageActionContext?: unknown;
};
if (
@@ -171,6 +175,9 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
}
const agentId = normalizeAgentId(raw.agentId);
const sessionKey = raw.sessionKey.trim();
const turnSourceAccountId = normalizeOptionalAccountId(
typeof raw.turnSourceAccountId === "string" ? raw.turnSourceAccountId : undefined,
);
if (!agentId || !sessionKey) {
return undefined;
}
@@ -185,6 +192,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND,
agentId,
sessionKey,
...(turnSourceAccountId ? { turnSourceAccountId } : {}),
...(messageActionContext ? { messageActionContext } : {}),
};
} catch {
@@ -196,6 +204,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
export async function mintAgentRuntimeIdentityToken(params: {
agentId: string;
sessionKey: string;
turnSourceAccountId?: string;
messageActionContext?: AgentRuntimeMessageActionContext;
}): Promise<string> {
if (
@@ -215,10 +224,12 @@ export async function mintAgentRuntimeIdentityToken(params: {
),
}
: undefined;
const turnSourceAccountId = normalizeOptionalAccountId(params.turnSourceAccountId);
const payload = encodePayload({
kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND,
agentId: normalizeAgentId(params.agentId),
sessionKey: params.sessionKey.trim(),
...(turnSourceAccountId ? { turnSourceAccountId } : {}),
...(messageActionContext ? { messageActionContext } : {}),
});
const signature = signPayload(await requireSharedAgentRuntimeIdentitySecret(), payload);
@@ -250,6 +261,7 @@ export async function verifyAgentRuntimeIdentityToken(
kind: "agentRuntime",
agentId: payload.agentId,
sessionKey: payload.sessionKey,
...(payload.turnSourceAccountId ? { turnSourceAccountId: payload.turnSourceAccountId } : {}),
...(payload.messageActionContext ? { messageActionContext: payload.messageActionContext } : {}),
};
}

View File

@@ -607,6 +607,7 @@ export function buildGatewayCronService(params: {
streamBatch,
toolsAllow: job.payload.toolsAllow,
ownerSessionKey: job.owner?.sessionKey,
ownerAccountId: job.owner?.accountId,
abortSignal,
}),
}
@@ -838,6 +839,7 @@ export function buildGatewayCronService(params: {
streamBatch,
toolsAllow: job.payload.toolsAllow,
ownerSessionKey: job.owner?.sessionKey,
ownerAccountId: job.owner?.accountId,
timeoutSeconds: job.payload.timeoutSeconds,
toolBudget: job.payload.toolBudget,
abortSignal,

View File

@@ -39,6 +39,7 @@ export type RestoredCronContinuation = {
toolsAllow?: string[];
toolsAllowIsDefault?: boolean;
ownerSessionKey?: string;
ownerAccountId?: string;
cliSessionBindingFacts?: {
extraSystemPromptStatic?: string;
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";

View File

@@ -355,6 +355,7 @@ export function startAgentRunExecution(params: {
? resolveScheduledToolPolicyContext({
toolsAllow: params.restoredCronContinuation.toolsAllow,
ownerSessionKey: params.restoredCronContinuation.ownerSessionKey,
ownerAccountId: params.restoredCronContinuation.ownerAccountId,
})
: undefined,
requireExplicitMessageTarget:

View File

@@ -230,6 +230,9 @@ export async function persistAgentSessionPhase(params: {
...(marker.ownerSessionKey?.trim()
? { ownerSessionKey: marker.ownerSessionKey.trim() }
: {}),
...(marker.ownerAccountId?.trim()
? { ownerAccountId: marker.ownerAccountId.trim() }
: {}),
...(marker.cliSessionBindingFacts
? { cliSessionBindingFacts: { ...marker.cliSessionBindingFacts } }
: {}),

View File

@@ -1,4 +1,5 @@
import type { CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js";
import { normalizeAccountId } from "../../routing/account-id.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import type { GatewayClient } from "./types.js";
@@ -7,6 +8,7 @@ export type CronCallerScope = {
kind: "agentTool";
agentId: string;
sessionKey?: string;
accountId: string;
};
export function readCronCallerScope(
@@ -20,6 +22,7 @@ export function readCronCallerScope(
kind: "agentTool",
agentId: normalizeAgentId(identity.agentId),
sessionKey: identity.sessionKey?.trim() || undefined,
accountId: normalizeAccountId(identity.turnSourceAccountId),
};
}
@@ -75,11 +78,20 @@ export function cronJobMatchesCallerScope(params: {
if (isOperatorCommandCronJob(params.job)) {
return false;
}
const ownerAccountId = params.job.owner?.accountId;
// Operator-created records may name an account without an owner agent; account ownership is
// therefore an independent boundary, not a refinement of ownerAgentId.
if (ownerAccountId && normalizeAccountId(ownerAccountId) !== params.callerScope.accountId) {
return false;
}
// Declarative jobs retain their stamped owner when an operator retargets execution.
// Ownerless jobs predate attribution, so keep their routing-based visibility.
const ownerAgentId = resolveCronJobOwnerAgentId(params.job);
if (ownerAgentId) {
return ownerAgentId === params.callerScope.agentId;
if (ownerAgentId !== params.callerScope.agentId) {
return false;
}
return true;
}
if (
resolveCronJobEffectiveAgentId(params.job, params.defaultAgentId) !== params.callerScope.agentId
@@ -154,6 +166,7 @@ export function applyCronCreateCallerScopeDefault(
owner: {
agentId: callerScope.agentId,
...(callerScope.sessionKey ? { sessionKey: callerScope.sessionKey } : {}),
accountId: callerScope.accountId,
},
};
}

View File

@@ -263,7 +263,7 @@ function createCronJob(overrides: Partial<CronJob> = {}): CronJob {
};
}
function callerClient(agentId: string): GatewayClient {
function callerClient(agentId: string, accountId?: string): GatewayClient {
return {
connect: {} as GatewayClient["connect"],
internal: {
@@ -271,6 +271,7 @@ function callerClient(agentId: string): GatewayClient {
kind: "agentRuntime",
agentId,
sessionKey: `agent:${agentId}:main`,
...(accountId ? { turnSourceAccountId: accountId } : {}),
},
},
};
@@ -1100,7 +1101,11 @@ describe("cron method validation", () => {
);
const payload = requireCronAddPayload(context);
expect(payload.owner).toEqual({ agentId: "ops", sessionKey: "agent:ops:main" });
expect(payload.owner).toEqual({
agentId: "ops",
sessionKey: "agent:ops:main",
accountId: "default",
});
const options = requireRecord(context.cron.add.mock.calls[0]?.[1], "cron.add options");
const matchesExisting = options.matchesExisting as ((job: CronJob) => boolean) | undefined;
expect(matchesExisting?.(createCronJob({ agentId: "ops" }))).toBe(true);
@@ -1108,12 +1113,33 @@ describe("cron method validation", () => {
expect(matchesExisting?.(createCronJob({ agentId: "worker", owner: { agentId: "ops" } }))).toBe(
true,
);
expect(
matchesExisting?.(
createCronJob({
agentId: "worker",
owner: { agentId: "ops", accountId: "work" },
}),
),
).toBe(false);
expect(matchesExisting?.(createCronJob({ agentId: "ops", owner: { agentId: "worker" } }))).toBe(
false,
);
expectCronSuccess(respond);
});
it("stamps declaration ownership from the authenticated source account", async () => {
const { context, respond } = await invokeCronAdd(agentTurnCronParams(), {
client: callerClient("ops", "work"),
});
expect(requireCronAddPayload(context).owner).toEqual({
agentId: "ops",
sessionKey: "agent:ops:main",
accountId: "work",
});
expectCronSuccess(respond);
});
it("keeps scoped read access with the stamped owner after operator retargeting", async () => {
const job = createCronJob({
agentId: "worker",
@@ -1134,6 +1160,73 @@ describe("cron method validation", () => {
);
});
it("keeps new jobs scoped to their creator account while preserving accountless legacy jobs", async () => {
const workOwned = createCronJob({
owner: { agentId: "ops", sessionKey: "agent:ops:main", accountId: "work" },
});
const context = createCronContext(workOwned);
const wrongAccount = await invokeCron(
"cron.list",
{ compact: true },
{ context, client: callerClient("ops", "default") },
);
expect(wrongAccount.respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ total: 0, jobs: [] }),
undefined,
);
const matchingAccount = await invokeCron(
"cron.list",
{ compact: true },
{ context, client: callerClient("ops", "work") },
);
expect(matchingAccount.respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ total: 1 }),
undefined,
);
const legacyContext = createCronContext(
createCronJob({ owner: { agentId: "ops", sessionKey: "agent:ops:main" } }),
);
const legacy = await invokeCron(
"cron.list",
{ compact: true },
{ context: legacyContext, client: callerClient("ops", "other") },
);
expect(legacy.respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ total: 1 }),
undefined,
);
const accountOnlyContext = createCronContext(
createCronJob({ agentId: "ops", owner: { accountId: "work" } }),
);
const accountOnlyWrong = await invokeCron(
"cron.list",
{ compact: true },
{ context: accountOnlyContext, client: callerClient("ops", "other") },
);
expect(accountOnlyWrong.respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ total: 0, jobs: [] }),
undefined,
);
const accountOnlyMatch = await invokeCron(
"cron.list",
{ compact: true },
{ context: accountOnlyContext, client: callerClient("ops", "work") },
);
expect(accountOnlyMatch.respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ total: 1 }),
undefined,
);
});
it("keeps explicit declaration ownership for operator callers", async () => {
const owner = { agentId: "ops", sessionKey: "agent:ops:main" };
const { context, respond } = await invokeCronAdd(

View File

@@ -26,7 +26,7 @@ type CreateOpenClawCodingToolsArg = {
workspaceDir?: string;
cwd?: string;
wrapBeforeToolCallHook?: boolean;
scheduledToolPolicy?: { ownerSessionKey: string };
scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string };
};
type LazyExecToolDefaults = {
@@ -161,7 +161,10 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
surface: "loopback",
excludeToolNames: ["read", "edit", "apply_patch", "exec", "process"],
mediatedToolNames: ["write"],
scheduledToolPolicy: { ownerSessionKey: "agent:main:qa-channel:group:ops" },
scheduledToolPolicy: {
ownerSessionKey: "agent:main:qa-channel:group:ops",
ownerAccountId: "default",
},
});
expect(result.tools.map((tool) => tool.name)).toContain("write");
@@ -173,7 +176,10 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
workspaceDir: "/workspace",
cwd: "/workspace/task",
wrapBeforeToolCallHook: false,
scheduledToolPolicy: { ownerSessionKey: "agent:main:qa-channel:group:ops" },
scheduledToolPolicy: {
ownerSessionKey: "agent:main:qa-channel:group:ops",
ownerAccountId: "default",
},
}),
);
expect(hoisted.createLazyExecToolMock).not.toHaveBeenCalled();

View File

@@ -1,3 +1,4 @@
import { WORKER_LAUNCH_V2_PROTOCOL_FEATURE } from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
import {
isUnavailableEnvironment,
type WorkerActiveDispatchPlacement,
@@ -12,6 +13,15 @@ import {
} from "./placement-dispatch-pending-results.js";
import type { WorkerEnvironmentService } from "./service.js";
function supportsWorkerLaunchV2(environment: ReturnType<WorkerEnvironmentService["get"]>): boolean {
// A persisted bundle hash can still match a pre-v2 worker when current bundle preparation fails.
// Require the admitted receipt so restart recovery never revives an incompatible launch contract.
return (
environment?.bootstrapReceipt?.protocolFeatures.includes(WORKER_LAUNCH_V2_PROTOCOL_FEATURE) ===
true
);
}
function sameActiveEnvironment(
placement: WorkerActiveDispatchPlacement | WorkerDrainingDispatchPlacement,
environment: ReturnType<WorkerEnvironmentService["get"]>,
@@ -25,6 +35,7 @@ function sameActiveEnvironment(
environment.ownerEpoch === placement.activeOwnerEpoch &&
placement.workerBundleHash &&
environment.bootstrapReceipt?.bundleHash === placement.workerBundleHash &&
supportsWorkerLaunchV2(environment) &&
environment.attachedSessionIds.length === 1 &&
environment.attachedSessionIds[0] === placement.sessionId,
);
@@ -120,6 +131,7 @@ export function createPlacementRecoveryActions(deps: PlacementRecoveryDeps) {
environment &&
expectedBundle &&
environment.bootstrapReceipt?.bundleHash === expectedBundle &&
supportsWorkerLaunchV2(environment) &&
hasSyncedWorkspace;
if (!canResume) {
const error = new Error("Interrupted worker dispatch cannot safely resume");

View File

@@ -1,4 +1,7 @@
import type { WorkerAdmissionHandshake } from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
import {
WORKER_LAUNCH_V2_PROTOCOL_FEATURE,
type WorkerAdmissionHandshake,
} from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
import type { WorkerProfile, WorkerSshEndpoint } from "../../plugins/types.js";
import type { WorkerDispatchEnvironmentService } from "./placement-dispatch-failure.js";
import type { createWorkerPlacementDispatchService } from "./placement-dispatch.js";
@@ -86,7 +89,7 @@ export function createDispatchEnvironmentFixtures() {
const bootstrapReceipt: WorkerAdmissionHandshake = {
bundleHash: BUNDLE_HASH,
openclawVersion: "2026.7.2",
protocolFeatures: [],
protocolFeatures: [WORKER_LAUNCH_V2_PROTOCOL_FEATURE],
};
const sshEndpoint: WorkerSshEndpoint = {
host: "worker.example.test",

View File

@@ -373,6 +373,15 @@ export function createHarness(
markEnvironmentAttachments: (attachedSessionIds: string[]) => {
currentEnvironment = { ...attached, attachedSessionIds };
},
markEnvironmentProtocolFeatures: (protocolFeatures: string[]) => {
if (!currentEnvironment?.bootstrapReceipt) {
throw new Error("worker environment fixture has no bootstrap receipt");
}
currentEnvironment = {
...currentEnvironment,
bootstrapReceipt: { ...currentEnvironment.bootstrapReceipt, protocolFeatures },
};
},
service,
ready,
attached,

View File

@@ -458,6 +458,23 @@ describe("worker placement dispatch", () => {
expect(harness.environments.destroy).not.toHaveBeenCalled();
});
it("reclaims an active pre-v2 worker instead of adopting its stale launch contract", async () => {
const harness = createHarness(placementStore);
await harness.environments.attachSession({
environmentId: harness.ready.environmentId,
ownerEpoch: harness.ready.ownerEpoch,
sessionId: REQUEST.sessionId,
});
harness.placements.seedActive(harness.attached.ownerEpoch);
harness.markEnvironmentProtocolFeatures([]);
await harness.service.reconcile();
expect(harness.placements.current()).toMatchObject({ state: "reclaimed" });
expect(harness.environments.startTunnel).not.toHaveBeenCalled();
expect(harness.environments.destroy).toHaveBeenCalledOnce();
});
it("reclaims an active placement whose environment is already terminal after restart", async () => {
const harness = createHarness(placementStore);
harness.placements.seedActive(harness.attached.ownerEpoch);
@@ -580,6 +597,21 @@ describe("worker placement dispatch", () => {
expect(harness.environments.create).not.toHaveBeenCalled();
});
it("tears down a starting pre-v2 worker instead of resuming its stale launch contract", async () => {
const harness = createHarness(placementStore);
harness.placements.seedStarting();
harness.markEnvironmentProtocolFeatures([]);
await harness.service.reconcile();
expect(harness.placements.current()).toMatchObject({
state: "failed",
recoveryError: "Interrupted worker dispatch cannot safely resume",
});
expect(harness.environments.startTunnel).not.toHaveBeenCalled();
expect(harness.environments.destroy).toHaveBeenCalledOnce();
});
it("finishes an interrupted drain through reconciliation before failure", async () => {
const harness = createHarness(placementStore);
harness.placements.seedDraining(harness.attached.ownerEpoch);

View File

@@ -60,7 +60,10 @@ describe("resolveWorkerToolAuthority", () => {
messageProvider: "whatsapp",
senderId: "guest",
toolsAllow: ["read", "write", "exec"],
scheduledToolPolicy: { ownerSessionKey: "agent:main:whatsapp:group:team" },
scheduledToolPolicy: {
ownerSessionKey: "agent:main:whatsapp:group:team",
ownerAccountId: "default",
},
}),
).toEqual(["read", "write", "apply_patch"]);
expect(
@@ -85,7 +88,10 @@ describe("resolveWorkerToolAuthority", () => {
},
messageProvider: "whatsapp",
toolsAllow: ["write"],
scheduledToolPolicy: { ownerSessionKey: "agent:main:whatsapp:group:team" },
scheduledToolPolicy: {
ownerSessionKey: "agent:main:whatsapp:group:team",
ownerAccountId: "default",
},
}),
).toEqual([]);
});

View File

@@ -22,7 +22,10 @@ describe("assertSupportedTurn", () => {
},
},
toolsAllow: ["write"],
scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" },
scheduledToolPolicy: {
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
},
} as SessionPlacementTurnParams),
).toEqual({ provider: "openai", model: "gpt-5.4" });
});