fix(cron): migrate scheduled authority provenance

This commit is contained in:
joshavant
2026-07-23 19:47:59 -05:00
committed by Josh Avant
parent f24361a32a
commit bd559a98ed
53 changed files with 1142 additions and 146 deletions

View File

@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Cron scheduled tool authority:** persist versioned creator provenance for tool-capped jobs, recover pre-v2026.7.2 account authority only when stored identity proves it, and surface ambiguous legacy jobs for explicit operator reauthorization instead of silently widening access. Fixes #111809. (#112661)
- **ClickClack split-origin setup codes:** consume versioned exact claim endpoints without appending a second claim path, validate the returned canonical API base, preserve private API transport overrides, and keep legacy setup URLs working. Fixes #111919. Thanks @shakkernerd.
- **Standalone plugin files:** let manifestless files explicitly listed in `plugins.load.paths` pass config validation and load independently when several files share a directory.
- **Control UI terminal error messages:** preserve message-only assistant output beginning with `Error:` or a warning marker instead of treating text prefixes as synthetic failures. Thanks @shakkernerd.

View File

@@ -461,6 +461,8 @@ describe("Codex app-server dynamic tool build", () => {
};
params.trustedInternalHandoff = true;
params.scheduledToolPolicy = {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
};

View File

@@ -728,6 +728,8 @@ describe("createCopilotToolBridge", () => {
memoryFlushWritePath: ".memory/append.md",
toolsAllow: ["read", "edit"],
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
},
@@ -747,6 +749,8 @@ describe("createCopilotToolBridge", () => {
// renamed key, so the bridge must surface the renamed shape too.
expect(opts.runtimeToolAllowlist).toEqual(["read", "edit"]);
expect(opts.scheduledToolPolicy).toEqual({
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
});

View File

@@ -36,6 +36,17 @@ describe("cron protocol validators", () => {
expect(validateCronAddParams(minimalAddParams)).toBe(true);
});
it("rejects client-authored scheduled authority provenance", () => {
const scheduledToolPolicy = { version: 1, mode: "trusted" } as const;
expect(validateCronAddParams({ ...minimalAddParams, scheduledToolPolicy })).toBe(false);
expect(
validateCronUpdateParams({
id: "job-1",
patch: { scheduledToolPolicy },
}),
).toBe(false);
});
it("accepts failure alert field clears only in update patches", () => {
const failureAlert = {
after: null,

View File

@@ -132,6 +132,15 @@ const CronOwnerSchema = closedObject({
sessionKey: Type.Optional(NonEmptyString),
accountId: Type.Optional(NonEmptyString),
});
const CronScheduledToolPolicySchema = Type.Union([
closedObject({ version: Type.Literal(1), mode: Type.Literal("trusted") }),
closedObject({
version: Type.Literal(1),
mode: Type.Literal("account"),
ownerSessionKey: NonEmptyString,
ownerAccountId: NonEmptyString,
}),
]);
const CronAnnounceChannelSchema = Type.Union([Type.Literal("last"), NonBlankString]);
const CronFailoverReasonSchema = Type.Union([
Type.Literal("auth"),
@@ -515,6 +524,7 @@ export const CronJobSchema = closedObject({
declarationKey: Type.Optional(CronDeclarationKeySchema),
displayName: Type.Optional(CronDisplayNameSchema),
owner: Type.Optional(CronOwnerSchema),
scheduledToolPolicy: Type.Optional(CronScheduledToolPolicySchema),
agentId: Type.Optional(NonEmptyString),
sessionKey: Type.Optional(NonEmptyString),
name: NonEmptyString,

View File

@@ -19,6 +19,7 @@ const SCENARIOS = new Set([
"tilde-log-path",
"meeting-transcripts-sqlite",
"versioned-runtime-deps",
"cron-scheduled-authority",
]);
const PERSONA_FILES = new Map([
@@ -184,6 +185,67 @@ function seedLegacyMeetingTranscripts(stateDir) {
write(path.join(sessionDir, "summary.md"), "# Design review\n\nShipped transcript summary.\n");
}
function seedLegacyCronScheduledAuthority(stateDir) {
const createdAtMs = Date.parse("2026-07-01T10:00:00.000Z");
const base = {
enabled: true,
createdAtMs,
updatedAtMs: createdAtMs,
schedule: { kind: "every", everyMs: 3_600_000, anchorMs: createdAtMs },
sessionTarget: "isolated",
wakeMode: "now",
delivery: { mode: "none" },
state: { nextRunAtMs: createdAtMs + 3_600_000 },
};
writeJson(path.join(stateDir, "cron", "jobs.json"), {
version: 1,
jobs: [
{
...base,
id: "cron-pre-cap",
name: "Pre-cap agent job",
payload: { kind: "agentTurn", message: "pre-cap" },
},
{
...base,
id: "cron-ownerless-cap",
name: "Ownerless capped job",
payload: { kind: "agentTurn", message: "ownerless", toolsAllow: ["write"] },
},
{
...base,
id: "cron-owner-session",
name: "Persisted owner session",
owner: {
agentId: "main",
sessionKey: "agent:main:discord:group:ops",
},
payload: { kind: "agentTurn", message: "owned", toolsAllow: ["write"] },
},
{
...base,
id: "cron-encoded-account",
name: "Encoded owner account",
owner: {
agentId: "main",
sessionKey: "agent:main:discord:personal:direct:user-1",
},
payload: { kind: "agentTurn", message: "encoded", toolsAllow: ["write"] },
},
{
...base,
id: "cron-agent-mismatch",
name: "Mismatched owner agent",
owner: {
agentId: "other",
sessionKey: "agent:main:discord:work:direct:user-2",
},
payload: { kind: "agentTurn", message: "mismatch", toolsAllow: ["write"] },
},
],
});
}
function getScenario() {
const scenario = process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO || "base";
assert(SCENARIOS.has(scenario), `unknown upgrade survivor scenario: ${scenario}`);
@@ -244,6 +306,9 @@ function seedState() {
if (scenario === "meeting-transcripts-sqlite") {
seedLegacyMeetingTranscripts(stateDir);
}
if (scenario === "cron-scheduled-authority") {
seedLegacyCronScheduledAuthority(stateDir);
}
const runtimeRoot = path.join(stateDir, "plugin-runtime-deps");
for (const plugin of ["discord", "telegram", "whatsapp"]) {
@@ -483,6 +548,9 @@ function assertStateSurvived() {
if (scenario === "meeting-transcripts-sqlite") {
assertMeetingTranscriptsMigrated(stateDir, stage);
}
if (scenario === "cron-scheduled-authority") {
assertCronScheduledAuthorityMigrated(stateDir, stage);
}
const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps");
if (stage === "baseline") {
if (fs.existsSync(legacyRuntimeRoot)) {
@@ -526,6 +594,60 @@ function assertStateSurvived() {
}
}
function assertCronScheduledAuthorityMigrated(stateDir, stage) {
const legacyStorePath = path.join(stateDir, "cron", "jobs.json");
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
if (stage === "baseline") {
if (fs.existsSync(legacyStorePath)) {
const jobs = readJson(legacyStorePath).jobs ?? [];
assert(jobs.length === 5, "legacy cron authority fixture row count changed before update");
return;
}
assert(fs.existsSync(databasePath), "legacy cron authority fixture missing before update");
const db = new DatabaseSync(databasePath, { readOnly: true });
try {
const rows = db.prepare("SELECT job_json FROM cron_jobs WHERE job_id LIKE 'cron-%'").all();
assert(rows.length === 5, "baseline cron authority fixture row count changed");
assert(
rows.every((row) => JSON.parse(row.job_json).scheduledToolPolicy === undefined),
"baseline unexpectedly authored current scheduled authority provenance",
);
} finally {
db.close();
}
return;
}
const db = new DatabaseSync(databasePath, { readOnly: true });
try {
const rows = db
.prepare("SELECT job_id, job_json FROM cron_jobs WHERE job_id LIKE 'cron-%'")
.all();
const jobs = new Map(rows.map((row) => [row.job_id, JSON.parse(row.job_json)]));
assert(jobs.size === 5, `cron authority fixture row count changed: ${jobs.size}`);
assert(
jobs.get("cron-encoded-account")?.scheduledToolPolicy?.ownerAccountId === "personal",
"session-encoded account authority was not recovered",
);
assert(
jobs.get("cron-encoded-account")?.owner?.accountId === "personal",
"session-encoded account was not projected onto the owner",
);
for (const id of [
"cron-pre-cap",
"cron-ownerless-cap",
"cron-owner-session",
"cron-agent-mismatch",
]) {
assert(
jobs.get(id)?.scheduledToolPolicy === undefined,
`ambiguous legacy job unexpectedly gained scheduled authority: ${id}`,
);
}
} finally {
db.close();
}
}
function assertMeetingTranscriptsMigrated(stateDir, stage) {
const legacySessionDir = path.join(stateDir, "transcripts", "2026-07-01", "design-review");
if (stage === "baseline") {

View File

@@ -88,6 +88,7 @@ const UPGRADE_SURVIVOR_SCENARIOS = [
"tilde-log-path",
"meeting-transcripts-sqlite",
"versioned-runtime-deps",
"cron-scheduled-authority",
];
const UPGRADE_SURVIVOR_SCENARIO_ALIASES = new Map([
@@ -98,6 +99,10 @@ const UPGRADE_SURVIVOR_SCENARIO_ALIASES = new Map([
// Pre-protocol catalogs are content-addressed. Unknown legacy blocks fail
// closed instead of requiring a dependency or reimplementing a JavaScript parser.
const LEGACY_UPGRADE_SURVIVOR_SCENARIO_CATALOGS = new Map([
[
"10ea475027d8b320d6a704cd6e4dd0f7e984c57a93b327048db229a7e0132c8a",
"base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority",
],
[
"213e004a28814fe0f7bb33018ae59a709c2e6d6e13b273df82a0e7935fbaf5af",
"base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps",

View File

@@ -935,6 +935,8 @@ describe("createOpenClawCodingTools", () => {
messageTo: "channel:123",
agentAccountId: "work",
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "creator",
},
@@ -1017,6 +1019,8 @@ describe("createOpenClawCodingTools", () => {
config: testConfig,
agentAccountId: "delivery",
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "creator",
},

View File

@@ -2435,6 +2435,8 @@ describe("prepareCliRunContext", () => {
runId: "run-test-loopback-prompt-tools",
config: createCliBackendConfig({ bundleMcp: true }),
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:worker:discord:group:ops",
ownerAccountId: "default",
},
@@ -2465,6 +2467,8 @@ describe("prepareCliRunContext", () => {
modelProvider: "native-cli",
modelId: "test-model",
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:worker:discord:group:ops",
ownerAccountId: "default",
},
@@ -2989,6 +2993,8 @@ describe("prepareCliRunContext", () => {
provider: "claude-cli",
toolsAllow: ["write"],
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
},
@@ -3005,6 +3011,8 @@ describe("prepareCliRunContext", () => {
"apply_patch",
]);
expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.scheduledToolPolicy).toEqual({
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
});
@@ -3012,6 +3020,8 @@ describe("prepareCliRunContext", () => {
expect.objectContaining({
toolsAllow: ["write"],
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
},

View File

@@ -197,6 +197,8 @@ describe("resolveConversationCapabilityProfile", () => {
const scheduled = resolveConversationCapabilityProfile({
...baseParams,
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:whatsapp:group:team",
ownerAccountId: "default",
},

View File

@@ -231,7 +231,7 @@ export function resolveConversationCapabilityProfile(
trustedInternalHandoff: params.trustedInternalHandoff,
senderPolicyMode: params.scheduledToolPolicy || isOwnerInternalSession ? "never" : "always",
groupPolicySessionKey: params.scheduledToolPolicy?.ownerSessionKey,
requireConfiguredGroupAccount: Boolean(params.scheduledToolPolicy),
requireConfiguredGroupAccount: params.scheduledToolPolicy?.mode === "account",
});
const { groupPolicy, senderPolicy, subagentPolicy, inheritedToolPolicy } = requesterPolicies;
const profilePolicy = resolveToolProfilePolicy(effective.profile);

View File

@@ -694,7 +694,7 @@ function resolvePluginHarnessToolPolicies(
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
accountId: params.scheduledToolPolicy?.ownerAccountId ?? params.agentAccountId,
requireConfiguredAccount: Boolean(params.scheduledToolPolicy),
requireConfiguredAccount: params.scheduledToolPolicy?.mode === "account",
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,

View File

@@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest";
import { resolveScheduledToolPolicyContext } from "./scheduled-tool-policy.js";
describe("resolveScheduledToolPolicyContext", () => {
it("requires a persisted cap and a trusted owner session/account pair", () => {
it("requires both a persisted cap and valid server provenance", () => {
expect(
resolveScheduledToolPolicyContext({
ownerSessionKey: "agent:main:discord:group:ops",
scheduledToolPolicy: { version: 1, mode: "trusted" },
}),
).toBeUndefined();
expect(
@@ -16,26 +16,28 @@ describe("resolveScheduledToolPolicyContext", () => {
expect(
resolveScheduledToolPolicyContext({
toolsAllow: ["write"],
ownerSessionKey: " ",
ownerAccountId: "work",
scheduledToolPolicy: { version: 2, mode: "trusted" },
}),
).toBeUndefined();
expect(
resolveScheduledToolPolicyContext({
toolsAllow: ["write"],
ownerSessionKey: "agent:main:discord:group:ops",
}),
resolveScheduledToolPolicyContext({ toolsAllow: ["write"], scheduledToolPolicy: {} }),
).toBeUndefined();
});
it("normalizes the trusted owner for explicitly capped runs", () => {
it("normalizes account provenance for explicitly capped runs", () => {
expect(
resolveScheduledToolPolicyContext({
toolsAllow: [],
ownerSessionKey: " agent:main:discord:group:ops ",
ownerAccountId: " work ",
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: " agent:main:discord:group:ops ",
ownerAccountId: " work ",
},
}),
).toEqual({
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
});

View File

@@ -1,25 +1,18 @@
/**
* Trusted runtime context for a scheduled run with a server-stamped tool cap.
* The owner session selects group policy; sender-specific policy was already
* projected into the immutable cap when the job was created.
*/
export type ScheduledToolPolicyContext = {
ownerSessionKey: string;
ownerAccountId: string;
};
import {
normalizeCronScheduledToolPolicy,
type CronScheduledToolPolicy,
} from "../cron/scheduled-tool-policy.js";
/** Trusted runtime context for a scheduled run with a server-stamped tool cap. */
export type ScheduledToolPolicyContext = CronScheduledToolPolicy;
/** 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;
scheduledToolPolicy?: unknown;
}): ScheduledToolPolicyContext | undefined {
if (params.toolsAllow === undefined) {
return undefined;
}
const ownerSessionKey = params.ownerSessionKey?.trim();
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;
return normalizeCronScheduledToolPolicy(params.scheduledToolPolicy);
}

View File

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

View File

@@ -15,6 +15,7 @@ import {
} from "./legacy-repair.js";
import {
formatLegacyIssuePreview,
formatScheduledToolPolicyAdvisory,
formatUnresolvedCommandPromptAdvisory,
formatUnresolvedShellPromptAdvisory,
} from "./repair-plan.js";
@@ -201,6 +202,29 @@ export async function collectLegacyCronStoreHealthFindings(params: {
}),
);
}
for (const [names, requirement, description] of [
[
normalized.legacyScheduledToolPolicyJobs,
"cron-scheduled-authority-reauthorization",
"require explicit scheduled authority reauthorization",
],
[
normalized.invalidScheduledToolPolicyJobs,
"cron-scheduled-authority-valid",
"have invalid scheduled authority provenance",
],
] as const) {
if (names.length > 0) {
findings.push(
legacyCronStoreFinding({
message: `${pluralize(names.length, "tool-bearing cron job")} ${description}.`,
path: storePath,
requirement,
fixHint: `Review with ${formatCliCommand("openclaw cron list")} and reauthorize with ${formatCliCommand("openclaw cron edit <id> --tools <tool,...>")}.`,
}),
);
}
}
if (sqliteProjectionBackfillCount > 0) {
findings.push(
@@ -376,6 +400,13 @@ export async function maybeRepairLegacyCronStore(params: {
if (shellPromptAdvisory) {
note(shellPromptAdvisory, "Cron");
}
const scheduledToolPolicyAdvisory = formatScheduledToolPolicyAdvisory({
legacyJobs: normalized.legacyScheduledToolPolicyJobs,
invalidJobs: normalized.invalidScheduledToolPolicyJobs,
});
if (scheduledToolPolicyAdvisory) {
note(scheduledToolPolicyAdvisory, "Cron");
}
const previewLines = formatLegacyIssuePreview(normalized.issues);
if (legacyStoreDetected) {
previewLines.unshift(

View File

@@ -53,6 +53,32 @@ export function formatUnresolvedShellPromptAdvisory(names: string[]): string | n
].join("\n");
}
/** Advisory for jobs whose scheduled authority cannot be recovered without a caller decision. */
export function formatScheduledToolPolicyAdvisory(params: {
legacyJobs: string[];
invalidJobs: string[];
}): string | null {
const lines: string[] = [];
if (params.legacyJobs.length > 0) {
lines.push(
`${pluralize(params.legacyJobs.length, "tool-bearing cron job")} ${params.legacyJobs.length === 1 ? "keeps" : "keep"} legacy sender-policy resolution because stored account authority is not provable${formatJobNameList(params.legacyJobs)}.`,
);
}
if (params.invalidJobs.length > 0) {
lines.push(
`${pluralize(params.invalidJobs.length, "tool-bearing cron job")} ${params.invalidJobs.length === 1 ? "has" : "have"} invalid or inconsistent scheduled authority provenance${formatJobNameList(params.invalidJobs)}.`,
);
}
if (lines.length === 0) {
return null;
}
lines.push(
"- These jobs continue through restrictive sender-policy resolution; doctor will not infer authority from delivery or current configuration.",
"- Reauthorize with `openclaw cron edit <id> --tools <tool,...>`, or use `--clear-tools` to adopt the current default cap.",
);
return lines.join("\n");
}
/** Convert legacy cron issue counts into doctor preview lines. */
export function formatLegacyIssuePreview(issues: CronLegacyIssueCounts): string[] {
const lines: string[] = [];
@@ -106,6 +132,11 @@ export function formatLegacyIssuePreview(issues: CronLegacyIssueCounts): string[
`- ${pluralize(issues.legacyDeliveryMode, "job")} still uses delivery mode \`deliver\``,
);
}
if (issues.migratedScheduledToolPolicy) {
lines.push(
`- ${pluralize(issues.migratedScheduledToolPolicy, "job")} can recover scheduled account authority from persisted owner identity`,
);
}
if (issues.invalidSchedule) {
lines.push(
`- ${pluralize(issues.invalidSchedule, "job")} has an invalid persisted schedule and will be removed`,
@@ -190,6 +221,7 @@ export function needsSqliteProjectionBackfill(params: {
"name",
"payload",
"schedule",
"scheduledToolPolicy",
"sessionKey",
"sessionTarget",
"wakeMode",

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { formatScheduledToolPolicyAdvisory } from "./repair-plan.js";
import { migrateScheduledToolPolicy } from "./scheduled-tool-policy-migration.js";
import { normalizeStoredCronJobs } from "./store-migration.js";
function job(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: "legacy",
name: "Legacy",
enabled: true,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_000_000,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
owner: {
agentId: "main",
sessionKey: "agent:main:discord:group:ops",
accountId: "work",
},
payload: { kind: "agentTurn", message: "run", toolsAllow: ["write"] },
...overrides,
};
}
describe("migrateScheduledToolPolicy", () => {
it("recovers an account from the persisted owner pair", () => {
const raw = job();
expect(migrateScheduledToolPolicy(raw)).toEqual({ mutated: true, status: "migrated" });
expect(raw.scheduledToolPolicy).toEqual({
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
});
});
it("recovers an account structurally encoded in a direct-session key", () => {
const raw = job({
owner: {
agentId: "main",
sessionKey: "agent:main:discord:work:direct:user-1",
},
});
expect(migrateScheduledToolPolicy(raw)).toEqual({ mutated: true, status: "migrated" });
expect(raw.owner).toMatchObject({ accountId: "work" });
});
it.each([
{
label: "agent mismatch",
owner: {
agentId: "other",
sessionKey: "agent:main:discord:work:direct:user-1",
accountId: "work",
},
},
{
label: "encoded account mismatch",
owner: {
agentId: "main",
sessionKey: "agent:main:discord:work:direct:user-1",
accountId: "personal",
},
},
{
label: "accountless owner",
owner: { agentId: "main", sessionKey: "agent:main:discord:group:ops" },
},
])("does not guess authority for $label", ({ owner }) => {
const raw = job({ owner });
expect(migrateScheduledToolPolicy(raw)).toEqual({ mutated: false, status: "legacy" });
expect(raw.scheduledToolPolicy).toBeUndefined();
});
it("keeps capless historical jobs on legacy sender policy", () => {
const raw = job({ payload: { kind: "agentTurn", message: "run" } });
expect(migrateScheduledToolPolicy(raw)).toEqual({ mutated: false, status: "legacy" });
});
it("rejects malformed and owner-inconsistent provenance", () => {
const malformed = job({ scheduledToolPolicy: { version: 2, mode: "trusted" } });
expect(migrateScheduledToolPolicy(malformed)).toEqual({
mutated: false,
status: "invalid",
});
const inconsistent = job({
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "personal",
},
});
expect(migrateScheduledToolPolicy(inconsistent)).toEqual({
mutated: false,
status: "invalid",
});
});
it("preserves valid trusted provenance", () => {
const raw = job({ scheduledToolPolicy: { version: 1, mode: "trusted" } });
expect(migrateScheduledToolPolicy(raw)).toEqual({ mutated: false, status: "current" });
});
it("reports auto-recoverable and ambiguous jobs through the doctor result", () => {
const recoverable = job();
const ambiguous = job({
id: "ambiguous",
name: "Ambiguous",
owner: { agentId: "main", sessionKey: "agent:main:discord:group:ops" },
});
const result = normalizeStoredCronJobs([recoverable, ambiguous]);
expect(result.issues.migratedScheduledToolPolicy).toBe(1);
expect(result.legacyScheduledToolPolicyJobs).toEqual(["Ambiguous"]);
expect(
formatScheduledToolPolicyAdvisory({
legacyJobs: result.legacyScheduledToolPolicyJobs,
invalidJobs: result.invalidScheduledToolPolicyJobs,
}),
).toContain("openclaw cron edit <id> --tools");
});
});

View File

@@ -0,0 +1,109 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
createAccountCronScheduledToolPolicy,
normalizeCronScheduledToolPolicy,
resolveCronScheduledToolPolicy,
} from "../../../cron/scheduled-tool-policy.js";
import { normalizeOptionalAccountId } from "../../../routing/account-id.js";
import { normalizeAgentId, parseSessionDeliveryRoute } from "../../../routing/session-key.js";
import { parseAgentSessionKey } from "../../../sessions/session-key-utils.js";
export type ScheduledToolPolicyMigrationResult = {
mutated: boolean;
status: "current" | "migrated" | "legacy" | "invalid" | "not-applicable";
};
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function usesToolRuntime(raw: Record<string, unknown>): boolean {
const payload = readRecord(raw.payload);
const trigger = readRecord(raw.trigger);
return (
payload?.kind === "agentTurn" ||
payload?.kind === "script" ||
(typeof trigger?.script === "string" && trigger.script.trim().length > 0)
);
}
/** Recovers only account authority proven by immutable persisted owner identity. */
export function migrateScheduledToolPolicy(
raw: Record<string, unknown>,
): ScheduledToolPolicyMigrationResult {
if (!usesToolRuntime(raw)) {
return {
mutated: false,
status: raw.scheduledToolPolicy === undefined ? "not-applicable" : "invalid",
};
}
const payload = readRecord(raw.payload);
const toolsAllow =
Array.isArray(payload?.toolsAllow) &&
payload.toolsAllow.every((value): value is string => typeof value === "string")
? payload.toolsAllow
: undefined;
const owner = readRecord(raw.owner);
const ownerSessionKey = normalizeOptionalString(owner?.sessionKey);
const ownerAccountId = normalizeOptionalAccountId(
typeof owner?.accountId === "string" ? owner.accountId : undefined,
);
if (raw.scheduledToolPolicy !== undefined) {
const normalized = normalizeCronScheduledToolPolicy(raw.scheduledToolPolicy);
const resolved = resolveCronScheduledToolPolicy({
toolsAllow,
scheduledToolPolicy: normalized,
owner: { sessionKey: ownerSessionKey, accountId: ownerAccountId },
});
if (!resolved) {
return { mutated: false, status: "invalid" };
}
const mutated = JSON.stringify(raw.scheduledToolPolicy) !== JSON.stringify(resolved);
if (mutated) {
raw.scheduledToolPolicy = resolved;
}
return { mutated, status: "current" };
}
// Capless historical jobs have no bounded authority to recover. They remain
// on legacy sender-policy resolution until an operator explicitly edits tools.
if (!toolsAllow || !ownerSessionKey) {
return { mutated: false, status: "legacy" };
}
const parsedSession = parseAgentSessionKey(ownerSessionKey);
if (!parsedSession) {
return { mutated: false, status: "legacy" };
}
const ownerAgentId = normalizeOptionalString(owner?.agentId);
if (ownerAgentId && normalizeAgentId(ownerAgentId) !== normalizeAgentId(parsedSession.agentId)) {
return { mutated: false, status: "legacy" };
}
const encodedAccountId = normalizeOptionalAccountId(
parseSessionDeliveryRoute(ownerSessionKey)?.accountId,
);
if (ownerAccountId && encodedAccountId && ownerAccountId !== encodedAccountId) {
return { mutated: false, status: "legacy" };
}
const recoveredAccountId = ownerAccountId ?? encodedAccountId;
if (!recoveredAccountId) {
return { mutated: false, status: "legacy" };
}
const scheduledToolPolicy = createAccountCronScheduledToolPolicy({
ownerSessionKey,
ownerAccountId: recoveredAccountId,
});
if (!scheduledToolPolicy) {
return { mutated: false, status: "legacy" };
}
raw.owner = {
...owner,
...(ownerAgentId ? { agentId: normalizeAgentId(ownerAgentId) } : {}),
sessionKey: ownerSessionKey,
accountId: recoveredAccountId,
};
raw.scheduledToolPolicy = scheduledToolPolicy;
return { mutated: true, status: "migrated" };
}

View File

@@ -25,6 +25,7 @@ import {
migrateLegacyAgentTurnCommandPayload,
migrateLegacyCronPayload,
} from "./payload-migration.js";
import { migrateScheduledToolPolicy } from "./scheduled-tool-policy-migration.js";
type CronStoreIssueKey =
| "jobId"
@@ -40,6 +41,7 @@ type CronStoreIssueKey =
| "legacyTopLevelPayloadFields"
| "legacyTopLevelDeliveryFields"
| "legacyDeliveryMode"
| "migratedScheduledToolPolicy"
| "invalidSchedule"
| "invalidPayload";
@@ -95,6 +97,8 @@ type NormalizeCronStoreJobsResult = {
issues: CronStoreIssues;
unresolvedAgentTurnCommandPromptJobs: string[];
unresolvedAgentTurnShellToolPromptJobs: string[];
legacyScheduledToolPolicyJobs: string[];
invalidScheduledToolPolicyJobs: string[];
jobs: Array<Record<string, unknown>>;
mutated: boolean;
removedJobs: Array<{ job: Record<string, unknown>; reason: string; sourceIndex: number }>;
@@ -306,6 +310,8 @@ export function normalizeStoredCronJobs(
const issues: CronStoreIssues = {};
const unresolvedAgentTurnCommandPromptJobs: string[] = [];
const unresolvedAgentTurnShellToolPromptJobs: string[] = [];
const legacyScheduledToolPolicyJobs: string[] = [];
const invalidScheduledToolPolicyJobs: string[] = [];
const unresolvedAgentTurnPromptJobsByKind = {
commandPromptWithoutShellAccess: unresolvedAgentTurnCommandPromptJobs,
shellToolPrompt: unresolvedAgentTurnShellToolPromptJobs,
@@ -698,6 +704,20 @@ export function normalizeStoredCronJobs(
mutated = true;
}
const scheduledPolicyMigration = migrateScheduledToolPolicy(raw);
if (scheduledPolicyMigration.mutated) {
mutated = true;
}
const scheduledPolicyJobName =
normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id);
if (scheduledPolicyMigration.status === "migrated") {
trackIssue("migratedScheduledToolPolicy");
} else if (scheduledPolicyMigration.status === "legacy" && scheduledPolicyJobName) {
legacyScheduledToolPolicyJobs.push(scheduledPolicyJobName);
} else if (scheduledPolicyMigration.status === "invalid" && scheduledPolicyJobName) {
invalidScheduledToolPolicyJobs.push(scheduledPolicyJobName);
}
const invalidPersistedReason = getInvalidPersistedCronJobReason(raw);
if (
invalidPersistedReason === "missing-schedule" ||
@@ -729,6 +749,8 @@ export function normalizeStoredCronJobs(
issues,
unresolvedAgentTurnCommandPromptJobs,
unresolvedAgentTurnShellToolPromptJobs,
legacyScheduledToolPolicyJobs,
invalidScheduledToolPolicyJobs,
jobs,
mutated,
removedJobs,

View File

@@ -10,6 +10,7 @@ import type { SessionObserverDigest } from "../../../packages/gateway-protocol/s
import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js";
import type { ChatType } from "../../channels/chat-type.js";
import type { ChannelId } from "../../channels/plugins/channel-id.types.js";
import type { CronScheduledToolPolicy } from "../../cron/scheduled-tool-policy.js";
import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js";
import type { Skill } from "../../skills/loading/skill-contract.js";
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
@@ -386,10 +387,8 @@ export type SessionEntry = SessionRestartRecoveryState &
cliExecutionProvider?: string;
toolsAllow?: string[];
toolsAllowIsDefault?: boolean;
/** Server-stamped creator session used to resolve sender-independent group policy. */
ownerSessionKey?: string;
/** Server-stamped creator account paired with ownerSessionKey. */
ownerAccountId?: string;
/** Exact server-stamped authority provenance copied from the owning cron job. */
scheduledToolPolicy?: CronScheduledToolPolicy;
cliSessionBindingFacts?: {
extraSystemPromptStatic?: string;
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";

View File

@@ -8,7 +8,6 @@ import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js";
import { wrapUntrustedPromptDataBlock } from "../../agents/sanitize-for-prompt.js";
import { resolveScheduledToolPolicyContext } from "../../agents/scheduled-tool-policy.js";
import { withLocalSessionPlacementTurnAdmission } from "../../agents/session-placement-admission.js";
import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js";
import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js";
@@ -27,6 +26,7 @@ import {
getGeneratedMediaTaskIdsForSessionKey,
hasNewGeneratedMediaTaskForSessionKey,
} from "../../tasks/task-status-access.js";
import { resolveCronScheduledToolPolicy } from "../scheduled-tool-policy.js";
import type { CronAgentExecutionPhaseUpdate, CronJob } from "../types.js";
import {
resolveCronChannelOutputPolicy,
@@ -274,10 +274,10 @@ function createCronPromptExecutor(params: {
params.cronSession.sessionEntry.systemPromptReport,
);
const bootstrapContextMode = resolveCronBootstrapContextMode(params.agentPayload);
const scheduledToolPolicy = resolveScheduledToolPolicyContext({
const scheduledToolPolicy = resolveCronScheduledToolPolicy({
toolsAllow: params.agentPayload?.toolsAllow,
ownerSessionKey: params.job.owner?.sessionKey,
ownerAccountId: params.job.owner?.accountId,
scheduledToolPolicy: params.job.scheduledToolPolicy,
owner: params.job.owner,
});
if (!params.sourceDelivery) {
logWarn(

View File

@@ -145,8 +145,12 @@ describe("createPersistCronSessionEntry", () => {
thinkingLevel: "high",
toolsAllow: ["image_generate", "write"],
toolsAllowIsDefault: true,
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
},
persistSessionEntry,
});
@@ -172,8 +176,12 @@ describe("createPersistCronSessionEntry", () => {
phase: "running",
toolsAllow: ["image_generate", "write"],
toolsAllowIsDefault: true,
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
},
},
});

View File

@@ -3,6 +3,7 @@ import fs from "node:fs";
import { isDeepStrictEqual } from "node:util";
import { clearBootstrapSnapshotOnSessionBoundary } from "../../agents/bootstrap-cache.js";
import type { LiveSessionModelSelection } from "../../agents/live-model-switch.js";
import { resolveScheduledToolPolicyContext } from "../../agents/scheduled-tool-policy.js";
import type { SessionEntry } from "../../config/sessions.js";
import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
import { mergeSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js";
@@ -10,6 +11,7 @@ import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marke
import { isCronSessionKey } from "../../sessions/session-key-utils.js";
import { isSessionWorkAdmissionActive } from "../../sessions/session-lifecycle-admission.js";
import type { SkillSnapshot } from "../../skills/types.js";
import type { CronScheduledToolPolicy } from "../scheduled-tool-policy.js";
import type { resolveCronSession } from "./session.js";
type MutableSessionStore = Record<string, SessionEntry>;
@@ -203,8 +205,7 @@ export function createCronRunContinuationSession(params: {
thinkingLevel?: string;
toolsAllow?: string[];
toolsAllowIsDefault?: boolean;
ownerSessionKey?: string;
ownerAccountId?: string;
scheduledToolPolicy?: CronScheduledToolPolicy;
cliSessionBindingFacts?: {
extraSystemPromptStatic?: string;
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
@@ -212,19 +213,16 @@ export function createCronRunContinuationSession(params: {
};
persistSessionEntry: PersistSessionEntry;
}): CronRunContinuationSession {
const scheduledToolPolicy = resolveScheduledToolPolicyContext({
toolsAllow: params.toolsAllow,
scheduledToolPolicy: params.scheduledToolPolicy,
});
const continuation: NonNullable<SessionEntry["cronRunContinuation"]> = {
lifecycleRevision: params.cronSession.lifecycleRevision,
phase: "running" as const,
...(params.toolsAllow !== undefined ? { toolsAllow: [...params.toolsAllow] } : {}),
...(params.toolsAllowIsDefault === true ? { toolsAllowIsDefault: true } : {}),
...(params.toolsAllow !== undefined &&
params.ownerSessionKey?.trim() &&
params.ownerAccountId?.trim()
? {
ownerSessionKey: params.ownerSessionKey.trim(),
ownerAccountId: params.ownerAccountId.trim(),
}
: {}),
...(scheduledToolPolicy ? { scheduledToolPolicy } : {}),
...(params.cliSessionBindingFacts
? { cliSessionBindingFacts: { ...params.cliSessionBindingFacts } }
: {}),

View File

@@ -52,6 +52,12 @@ function makeParamsWithToolsAllow(toolsAllow: string[]) {
...params,
job: {
...job,
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:whatsapp:group:team",
ownerAccountId: "default",
},
payload: {
kind: "agentTurn",
message: "check allowed tools",
@@ -68,6 +74,12 @@ function makeParamsWithDefaultToolsAllow(toolsAllow: string[]) {
...params,
job: {
...job,
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:whatsapp:group:team",
ownerAccountId: "default",
},
payload: {
kind: "agentTurn",
message: "check allowed tools",
@@ -81,13 +93,23 @@ function makeParamsWithDefaultToolsAllow(toolsAllow: string[]) {
function requireEmbeddedAgentCall(): {
jobId?: string;
toolsAllow?: string[];
scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string };
scheduledToolPolicy?: {
version: 1;
mode: "account";
ownerSessionKey: string;
ownerAccountId: string;
};
} {
const call = runEmbeddedAgentMock.mock.calls[0]?.[0] as
| {
jobId?: string;
toolsAllow?: string[];
scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string };
scheduledToolPolicy?: {
version: 1;
mode: "account";
ownerSessionKey: string;
ownerAccountId: string;
};
}
| undefined;
if (!call) {
@@ -164,6 +186,8 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => {
expect(call.jobId).toBe("tools-allow");
expect(call.toolsAllow).toEqual(["cron"]);
expect(call.scheduledToolPolicy).toEqual({
version: 1,
mode: "account",
ownerSessionKey: "agent:main:whatsapp:group:team",
ownerAccountId: "default",
});

View File

@@ -70,6 +70,7 @@ import {
mergeCronRunDiagnostics,
toolsAllowRequestsWebSearch,
} from "../run-diagnostics.js";
import { resolveCronScheduledToolPolicy } from "../scheduled-tool-policy.js";
import { resolveCronAbortReasonText } from "../service/execution-errors.js";
import { isDetachedCronSessionTarget, resolveCronDeliverySessionKey } from "../session-target.js";
import type {
@@ -1126,8 +1127,11 @@ async function prepareCronRunContext(params: {
thinkingLevel: requestedThinkLevel,
toolsAllow: agentPayload?.toolsAllow,
toolsAllowIsDefault: agentPayload?.toolsAllowIsDefault,
ownerSessionKey: input.job.owner?.sessionKey,
ownerAccountId: input.job.owner?.accountId,
scheduledToolPolicy: resolveCronScheduledToolPolicy({
toolsAllow: agentPayload?.toolsAllow,
scheduledToolPolicy: input.job.scheduledToolPolicy,
owner: input.job.owner,
}),
cliSessionBindingFacts: {
sourceReplyDeliveryMode: sourceDelivery.sourceReplyDeliveryMode,
requireExplicitMessageTarget: sourceDelivery.messageTool.requireExplicitTarget,

View File

@@ -14,6 +14,7 @@ import { parseDeliveryInput } from "./delivery-field-schemas.js";
import { normalizeCronCommandArgv, normalizeCronPayload } from "./normalize-payload.js";
import { parseAbsoluteTimeMs } from "./parse.js";
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
import { normalizeCronScheduledToolPolicy } from "./scheduled-tool-policy.js";
import { inferCronJobName } from "./service/normalize.js";
import {
assertSafeCronSessionTargetId,
@@ -385,6 +386,15 @@ export function normalizeCronJobInput(
}
}
if ("scheduledToolPolicy" in base) {
const scheduledToolPolicy = normalizeCronScheduledToolPolicy(base.scheduledToolPolicy);
if (scheduledToolPolicy) {
next.scheduledToolPolicy = scheduledToolPolicy;
} else {
delete next.scheduledToolPolicy;
}
}
if ("agentId" in base) {
const agentId = base.agentId;
if (agentId === null) {

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
createAccountCronScheduledToolPolicy,
normalizeCronScheduledToolPolicy,
resolveCronScheduledToolPolicy,
} from "./scheduled-tool-policy.js";
describe("cron scheduled tool policy", () => {
it("accepts only the closed current version", () => {
expect(normalizeCronScheduledToolPolicy({ version: 1, mode: "trusted" })).toEqual({
version: 1,
mode: "trusted",
});
expect(normalizeCronScheduledToolPolicy({ version: 2, mode: "trusted" })).toBeUndefined();
expect(
normalizeCronScheduledToolPolicy({ version: 1, mode: "trusted", ownerAccountId: "work" }),
).toBeUndefined();
});
it("requires account provenance to match the persisted owner", () => {
const policy = createAccountCronScheduledToolPolicy({
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
});
expect(
resolveCronScheduledToolPolicy({
toolsAllow: ["write"],
scheduledToolPolicy: policy,
owner: {
sessionKey: "agent:main:discord:group:ops",
accountId: "work",
},
}),
).toEqual(policy);
expect(
resolveCronScheduledToolPolicy({
toolsAllow: ["write"],
scheduledToolPolicy: policy,
owner: {
sessionKey: "agent:main:discord:group:ops",
accountId: "personal",
},
}),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,86 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeOptionalAccountId } from "../routing/account-id.js";
/** Server-authored provenance for a persisted scheduled tool-cap authority envelope. */
export type CronScheduledToolPolicy =
| {
version: 1;
mode: "trusted";
ownerSessionKey?: never;
ownerAccountId?: never;
}
| {
version: 1;
mode: "account";
ownerSessionKey: string;
ownerAccountId: string;
};
/** Creates provenance for an authenticated operator or trusted in-process caller. */
export function createTrustedCronScheduledToolPolicy(): CronScheduledToolPolicy {
return { version: 1, mode: "trusted" };
}
/** Creates requester-scoped provenance from an authenticated account identity. */
export function createAccountCronScheduledToolPolicy(params: {
ownerSessionKey: string;
ownerAccountId: string;
}): CronScheduledToolPolicy | undefined {
const ownerSessionKey = normalizeOptionalString(params.ownerSessionKey);
const ownerAccountId = normalizeOptionalAccountId(params.ownerAccountId);
if (!ownerSessionKey || !ownerAccountId) {
return undefined;
}
return { version: 1, mode: "account", ownerSessionKey, ownerAccountId };
}
/** Accepts only the current closed provenance shape; unknown versions fail closed. */
export function normalizeCronScheduledToolPolicy(
value: unknown,
): CronScheduledToolPolicy | undefined {
if (!isRecord(value) || value.version !== 1) {
return undefined;
}
if (value.mode === "trusted") {
return Object.keys(value).every((key) => key === "version" || key === "mode")
? createTrustedCronScheduledToolPolicy()
: undefined;
}
if (value.mode !== "account") {
return undefined;
}
const policy = createAccountCronScheduledToolPolicy({
ownerSessionKey: typeof value.ownerSessionKey === "string" ? value.ownerSessionKey : "",
ownerAccountId: typeof value.ownerAccountId === "string" ? value.ownerAccountId : "",
});
if (!policy) {
return undefined;
}
return Object.keys(value).every(
(key) =>
key === "version" || key === "mode" || key === "ownerSessionKey" || key === "ownerAccountId",
)
? policy
: undefined;
}
/** Resolves trusted provenance only when it is consistent with the persisted job owner. */
export function resolveCronScheduledToolPolicy(params: {
toolsAllow?: readonly string[];
scheduledToolPolicy?: unknown;
owner?: { sessionKey?: string; accountId?: string };
}): CronScheduledToolPolicy | undefined {
if (params.toolsAllow === undefined) {
return undefined;
}
const policy = normalizeCronScheduledToolPolicy(params.scheduledToolPolicy);
if (!policy || policy.mode === "trusted") {
return policy;
}
const ownerSessionKey = normalizeOptionalString(params.owner?.sessionKey);
const ownerAccountId = normalizeOptionalAccountId(params.owner?.accountId);
return ownerSessionKey === policy.ownerSessionKey && ownerAccountId === policy.ownerAccountId
? policy
: undefined;
}

View File

@@ -10,6 +10,7 @@ import type {
CronRunResult,
CronStatusSummary,
CronUpdateInput,
CronUpdateOptions,
CronUpdatePrecondition,
CronUpdateResult,
CronWakeMode,
@@ -41,11 +42,12 @@ export interface CronServiceContract {
list(opts?: { includeDisabled?: boolean }): Promise<CronListResult>;
listPage(opts?: CronListPageOptions): Promise<CronListPageResult>;
add(input: CronAddInput, opts?: CronAddOptions): Promise<CronAddResult>;
update(id: string, patch: CronUpdateInput): Promise<CronUpdateResult>;
update(id: string, patch: CronUpdateInput, opts?: CronUpdateOptions): Promise<CronUpdateResult>;
updateWithPrecondition(
id: string,
patch: CronUpdateInput,
precondition: CronUpdatePrecondition,
opts?: CronUpdateOptions,
): Promise<CronUpdateResult>;
remove(id: string, opts?: { systemOwned?: boolean }): Promise<CronRemoveResult>;
run(id: string, mode?: CronRunMode, opts?: CronServiceRunOptions): Promise<CronServiceRunResult>;

View File

@@ -10,6 +10,7 @@ import {
type CronAddOptions,
type CronServiceDeps,
type CronUpdatePrecondition,
type CronUpdateOptions,
type CronWakeMode,
createCronServiceState,
} from "./service/state.js";
@@ -105,16 +106,17 @@ export class CronService implements CronServiceContract {
return await ops.add(this.state, input, opts);
}
async update(id: string, patch: CronJobPatch) {
return await ops.update(this.state, id, patch);
async update(id: string, patch: CronJobPatch, opts?: CronUpdateOptions) {
return await ops.update(this.state, id, patch, opts);
}
async updateWithPrecondition(
id: string,
patch: CronJobPatch,
precondition: CronUpdatePrecondition,
opts?: CronUpdateOptions,
) {
return await ops.updateWithPrecondition(this.state, id, patch, precondition);
return await ops.updateWithPrecondition(this.state, id, patch, precondition, opts);
}
async remove(id: string, opts?: { systemOwned?: boolean }) {

View File

@@ -19,6 +19,11 @@ import {
computeNextRunAtMs,
computePreviousRunAtMs,
} from "../schedule.js";
import {
createTrustedCronScheduledToolPolicy,
resolveCronScheduledToolPolicy,
type CronScheduledToolPolicy,
} from "../scheduled-tool-policy.js";
import { normalizeCronScriptPayload } from "../script-payload.js";
import { assertSafeCronSessionTargetId } from "../session-target.js";
import {
@@ -1046,8 +1051,58 @@ export function nextWakeAtMs(state: CronServiceState) {
}, first);
}
/** Applies one canonical server-authored authority envelope to a tool-bearing job. */
function stampScheduledToolPolicy(
job: CronJob,
scheduledToolPolicy: CronScheduledToolPolicy | undefined,
): void {
if (!cronJobUsesToolRuntime(job) || job.payload.toolsAllow === undefined) {
delete job.scheduledToolPolicy;
return;
}
const policy = scheduledToolPolicy ?? createTrustedCronScheduledToolPolicy();
if (
policy.mode === "account" &&
(job.owner?.sessionKey !== policy.ownerSessionKey ||
job.owner?.accountId !== policy.ownerAccountId)
) {
throw new Error("scheduled account policy must match the persisted job owner");
}
job.scheduledToolPolicy = structuredClone(policy);
}
function reconcileScheduledToolPolicy(params: {
job: CronJob;
previouslyUsedToolRuntime: boolean;
explicitlyMutatesToolsAllow: boolean;
scheduledToolPolicy?: CronScheduledToolPolicy;
}): void {
const { job } = params;
if (!cronJobUsesToolRuntime(job) || job.payload.toolsAllow === undefined) {
delete job.scheduledToolPolicy;
return;
}
const current = resolveCronScheduledToolPolicy({
toolsAllow: job.payload.toolsAllow,
scheduledToolPolicy: job.scheduledToolPolicy,
owner: job.owner,
});
if (current) {
job.scheduledToolPolicy = current;
return;
}
delete job.scheduledToolPolicy;
if (params.explicitlyMutatesToolsAllow || !params.previouslyUsedToolRuntime) {
stampScheduledToolPolicy(job, params.scheduledToolPolicy);
}
}
/** Creates a normalized cron job row from public add input and computes its initial schedule. */
export function createJob(state: CronServiceState, input: CronJobCreate): CronJob {
export function createJob(
state: CronServiceState,
input: CronJobCreate,
opts?: { scheduledToolPolicy?: CronScheduledToolPolicy },
): CronJob {
const now = state.deps.nowMs();
const id = normalizeOptionalString(input.id) ?? crypto.randomUUID();
const schedule =
@@ -1141,6 +1196,7 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
// New trusted jobs are explicit by construction. Agent-runtime callers are
// required to arrive with a creator cap before the service can apply this default.
applyDefaultCronToolsAllow(job);
stampScheduledToolPolicy(job, opts?.scheduledToolPolicy);
assertSupportedJobSpec(job);
assertPacingSupport(job);
assertTriggerSupport(job, {
@@ -1171,6 +1227,7 @@ export function applyJobPatch(
defaultAgentId?: string;
scheduleValidationNowMs?: number;
cronConfig?: CronConfig;
scheduledToolPolicy?: CronScheduledToolPolicy;
},
) {
const previouslyUsedToolRuntime = cronJobUsesToolRuntime(job);
@@ -1268,6 +1325,13 @@ export function applyJobPatch(
// Ordinary edits to an existing capless job intentionally remain legacy.
applyDefaultCronToolsAllow(job);
}
reconcileScheduledToolPolicy({
job,
previouslyUsedToolRuntime,
explicitlyMutatesToolsAllow:
patch.payload !== undefined && Object.hasOwn(patch.payload, "toolsAllow"),
scheduledToolPolicy: opts?.scheduledToolPolicy,
});
if (patch.delivery) {
const implicitMode = resolveCronDeliveryPlan(job).mode;
job.delivery = mergeCronDelivery(job.delivery, patch.delivery, implicitMode);
@@ -1352,9 +1416,11 @@ export function applyDeclarativeJobSpec(
enabledExplicit: boolean;
nowMs: number;
cronConfig?: CronConfig;
scheduledToolPolicy?: CronScheduledToolPolicy;
},
) {
const previouslyUsedToolRuntime = cronJobUsesToolRuntime(job);
const explicitlyDeclaresToolsAllow = input.payload.toolsAllow !== undefined;
const previousToolsAllow = job.payload.toolsAllow;
const previousToolsAllowIsDefault = job.payload.toolsAllowIsDefault;
// Name, target, routing, owner, and run policy remain outside declaration
@@ -1424,6 +1490,12 @@ export function applyDeclarativeJobSpec(
applyDefaultCronToolsAllow(job);
}
}
reconcileScheduledToolPolicy({
job,
previouslyUsedToolRuntime,
explicitlyMutatesToolsAllow: explicitlyDeclaresToolsAllow,
scheduledToolPolicy: opts.scheduledToolPolicy,
});
const delivery = resolveInitialCronDelivery(input);
if (delivery) {
job.delivery = structuredClone(delivery);

View File

@@ -22,6 +22,95 @@ const { logger, makeStorePath } = setupCronServiceSuite({
prefix: "cron-service-ops-seam",
});
describe("scheduled tool policy provenance", () => {
it("stamps trusted and authenticated-account creates", async () => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-07-23T12:00:00.000Z");
const state = createOkIsolatedCronState({ storePath, now });
const base = {
enabled: true,
schedule: { kind: "every" as const, everyMs: 60_000 },
sessionTarget: "isolated" as const,
wakeMode: "now" as const,
payload: { kind: "agentTurn" as const, message: "run", toolsAllow: ["write"] },
};
const trusted = await add(state, { ...base, name: "trusted" });
expect(trusted.scheduledToolPolicy).toEqual({ version: 1, mode: "trusted" });
const account = await add(
state,
{
...base,
name: "account",
owner: {
agentId: "main",
sessionKey: "agent:main:discord:group:ops",
accountId: "work",
},
},
{
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
},
},
);
expect(account.scheduledToolPolicy).toEqual({
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
});
if (state.timer) {
clearTimeout(state.timer);
}
});
it("keeps routine legacy edits restrictive and adopts authority on an explicit tool edit", async () => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-07-23T12:00:00.000Z");
const state = createOkIsolatedCronState({ storePath, now });
const created = await add(state, {
name: "legacy",
enabled: true,
owner: {
agentId: "main",
sessionKey: "agent:main:discord:group:ops",
accountId: "work",
},
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "run", toolsAllow: ["write"] },
});
delete created.scheduledToolPolicy;
const routine = await update(state, created.id, { description: "routine" });
expect(routine.scheduledToolPolicy).toBeUndefined();
const reauthorized = await update(
state,
created.id,
{ payload: { kind: "agentTurn", toolsAllow: ["write"] } },
{
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
},
},
);
expect(reauthorized.scheduledToolPolicy?.mode).toBe("account");
if (state.timer) {
clearTimeout(state.timer);
}
});
});
async function withStateDirForStorePath<T>(
storePath: string,
runWithStateDir: () => Promise<T>,

View File

@@ -84,6 +84,7 @@ import type {
CronAddOptions,
CronEvent,
CronServiceState,
CronUpdateOptions,
CronUpdatePrecondition,
CronWakeMode,
} from "./state.js";
@@ -756,6 +757,7 @@ function declarativeFields(job: CronJob, includeEnabled: boolean) {
pacing: job.pacing,
trigger: job.trigger,
payload: job.payload,
scheduledToolPolicy: job.scheduledToolPolicy,
delivery: job.delivery,
displayName: job.displayName,
...(includeEnabled ? { enabled: job.enabled } : {}),
@@ -811,6 +813,7 @@ export async function add(state: CronServiceState, input: CronJobCreate, opts?:
enabledExplicit: opts?.enabledExplicit === true,
nowMs: now,
cronConfig: state.deps.cronConfig,
scheduledToolPolicy: opts?.scheduledToolPolicy,
});
const includeEnabled = opts?.enabledExplicit === true;
if (
@@ -837,7 +840,9 @@ export async function add(state: CronServiceState, input: CronJobCreate, opts?:
throw new Error(`cron job already exists: ${normalizedId}`);
}
const snapshot = snapshotStoreForRollback(state);
const job = createJob(state, normalizedInput);
const job = createJob(state, normalizedInput, {
scheduledToolPolicy: opts?.scheduledToolPolicy,
});
state.store?.jobs.push(job);
// Auto-disable notifications describe durable state, so publish them only
@@ -880,8 +885,9 @@ async function updateLoadedJob(params: {
id: string;
patch: CronJobPatch;
precondition?: CronUpdatePrecondition;
opts?: CronUpdateOptions;
}) {
const { state, id, patch, precondition } = params;
const { state, id, patch, precondition, opts } = params;
warnIfDisabled(state, "update");
// Mirrors the add-time boundary: no caller may patch a job into (or edit)
// the system-owned heartbeat payload; the gateway converges via add only.
@@ -907,6 +913,7 @@ async function updateLoadedJob(params: {
defaultAgentId: state.deps.defaultAgentId,
scheduleValidationNowMs: now,
cronConfig: state.deps.cronConfig,
scheduledToolPolicy: opts?.scheduledToolPolicy,
});
if (patch.agentId !== undefined) {
const agentId = resolveEffectiveJobAgentId(nextJob, resolveCurrentDefaultAgentId(state));
@@ -930,8 +937,13 @@ async function updateLoadedJob(params: {
}
/** Updates a cron job patch in-place, recomputes affected schedule state, and persists it. */
export async function update(state: CronServiceState, id: string, patch: CronJobPatch) {
return await locked(state, async () => await updateLoadedJob({ state, id, patch }));
export async function update(
state: CronServiceState,
id: string,
patch: CronJobPatch,
opts?: CronUpdateOptions,
) {
return await locked(state, async () => await updateLoadedJob({ state, id, patch, opts }));
}
/** Updates a cron job only after a store-locked caller precondition passes. */
@@ -940,8 +952,12 @@ export async function updateWithPrecondition(
id: string,
patch: CronJobPatch,
precondition: CronUpdatePrecondition,
opts?: CronUpdateOptions,
) {
return await locked(state, async () => await updateLoadedJob({ state, id, patch, precondition }));
return await locked(
state,
async () => await updateLoadedJob({ state, id, patch, precondition, opts }),
);
}
/** Removes a cron job by id and re-arms the timer when the in-memory store changes. */

View File

@@ -4,6 +4,7 @@ import type { HeartbeatRunResult, HeartbeatWakeRequest } from "../../infra/heart
import type { CommandLaneTaskMarker } from "../../process/command-queue.js";
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
import type { CronActiveJobMarker } from "../active-jobs.js";
import type { CronScheduledToolPolicy } from "../scheduled-tool-policy.js";
import type { QuarantinedCronConfigJob } from "../store.js";
import type {
CronTriggerEvaluationResult,
@@ -360,8 +361,14 @@ export type CronAddOptions = {
enabledExplicit?: boolean;
/** Gateway-owned system payloads (heartbeat monitors) require this opt-in. */
systemOwned?: boolean;
/** Authenticated caller provenance stamped by the service, never public input. */
scheduledToolPolicy?: CronScheduledToolPolicy;
};
/** Normalized patch input accepted by cron service updates. */
export type CronUpdateInput = CronJobPatch;
/** Authenticated caller provenance used only when a tool policy is explicitly adopted. */
export type CronUpdateOptions = {
scheduledToolPolicy?: CronScheduledToolPolicy;
};
/** Cron-store-locked guard evaluated against the current job before an update applies. */
export type CronUpdatePrecondition = (job: CronJob, nowMs: number) => void | Promise<void>;

View File

@@ -29,6 +29,32 @@ describe("schedule column codec round-trip", () => {
});
});
it("round-trips scheduled authority through the additive job_json envelope", () => {
const job = projectCronJobThroughStorageCodec(
makeCronJob({
owner: {
agentId: "main",
sessionKey: "agent:main:discord:group:ops",
accountId: "work",
},
payload: { kind: "agentTurn", message: "run", toolsAllow: ["write"] },
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
},
}),
);
expect(job.scheduledToolPolicy).toEqual({
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "work",
});
});
it("round-trips pacing through the additive job_json envelope", () => {
const job = projectCronJobThroughStorageCodec(
makeCronJob({ pacing: { min: "15m", max: "4h" } }),

View File

@@ -7,6 +7,7 @@ import { normalizeCronJobIdentityFields } from "../normalize-job-identity.js";
import { normalizeCronJobInput } from "../normalize.js";
import { getInvalidPersistedCronJobReason } from "../persisted-shape.js";
import { tryCronScheduleIdentity } from "../schedule-identity.js";
import { normalizeCronScheduledToolPolicy } from "../scheduled-tool-policy.js";
import type { CronJob, CronJobState, CronPacing, CronSchedule, CronStoreFile } from "../types.js";
import { bindDeliveryColumns, deliveryFromRow } from "./delivery-codec.js";
import { bindFailureAlertColumns, failureAlertFromRow } from "./failure-alert-codec.js";
@@ -283,6 +284,7 @@ function rowToCronJob(row: CronJobRow): CronJob | null {
const failureAlert = failureAlertFromRow(row);
const trigger = triggerFromRow(row);
const pacing = pacingFromRow(row);
const scheduledToolPolicy = normalizeCronScheduledToolPolicy(jobJson.scheduledToolPolicy);
if (!schedule || !payload) {
return null;
}
@@ -300,6 +302,7 @@ function rowToCronJob(row: CronJobRow): CronJob | null {
},
}
: {}),
...(scheduledToolPolicy ? { scheduledToolPolicy } : {}),
name: row.name,
...(row.description ? { description: row.description } : {}),
enabled: row.enabled !== 0,

View File

@@ -305,7 +305,7 @@ describe("cron trigger script evaluator", () => {
]);
});
it("forwards the owner session and invalidates cached authority when it changes", async () => {
it("forwards scheduled provenance and invalidates cached authority when it changes", async () => {
const config = {} as OpenClawConfig;
const prepareRuntime = vi.fn(async (_params: PrepareParams) => createPreparedRuntime(config));
const runHeadless = vi.fn(async () => completed({ value: { fire: false } }));
@@ -320,18 +320,28 @@ describe("cron trigger script evaluator", () => {
script: "return result",
state: null,
toolsAllow: ["write"],
ownerSessionKey,
ownerAccountId,
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey,
ownerAccountId,
},
});
}
expect(prepareRuntime.mock.calls.map(([params]) => params.ownerSessionKey)).toEqual([
"agent:main:discord:group:a",
"agent:main:discord:group:b",
]);
expect(prepareRuntime.mock.calls.map(([params]) => params.ownerAccountId)).toEqual([
"alpha",
"beta",
expect(prepareRuntime.mock.calls.map(([params]) => params.scheduledToolPolicy)).toEqual([
{
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:a",
ownerAccountId: "alpha",
},
{
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:b",
ownerAccountId: "beta",
},
]);
});

View File

@@ -28,7 +28,10 @@ import {
} from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js";
import { ensureRuntimePluginsLoaded } from "../agents/runtime-plugins.js";
import { resolveSandboxContext } from "../agents/sandbox.js";
import { resolveScheduledToolPolicyContext } from "../agents/scheduled-tool-policy.js";
import {
resolveScheduledToolPolicyContext,
type ScheduledToolPolicyContext,
} from "../agents/scheduled-tool-policy.js";
import {
createToolSearchCatalogRef,
registerHeadlessToolSearchCatalog,
@@ -82,8 +85,7 @@ type PrepareTriggerRuntime = (params: {
jobId: string;
agentId?: string;
toolsAllow?: string[];
ownerSessionKey?: string;
ownerAccountId?: string;
scheduledToolPolicy?: ScheduledToolPolicyContext;
signal?: AbortSignal;
}) => Promise<PreparedTriggerRuntime>;
@@ -109,8 +111,7 @@ async function prepareTriggerRuntime(params: {
jobId: string;
agentId?: string;
toolsAllow?: string[];
ownerSessionKey?: string;
ownerAccountId?: string;
scheduledToolPolicy?: ScheduledToolPolicyContext;
signal?: AbortSignal;
}): Promise<PreparedTriggerRuntime> {
params.signal?.throwIfAborted();
@@ -180,8 +181,7 @@ async function prepareTriggerRuntime(params: {
inheritRuntimeToolAllowlist: Boolean(toolPlan.runtimeToolAllowlist),
scheduledToolPolicy: resolveScheduledToolPolicyContext({
toolsAllow: params.toolsAllow,
ownerSessionKey: params.ownerSessionKey,
ownerAccountId: params.ownerAccountId,
scheduledToolPolicy: params.scheduledToolPolicy,
}),
toolConstructionPlan: toolPlan.codingToolConstructionPlan,
})
@@ -351,8 +351,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
requestedAgentId?: string;
agentId: string;
toolsAllow?: string[];
ownerSessionKey?: string;
ownerAccountId?: string;
scheduledToolPolicy?: ScheduledToolPolicyContext;
toolsAllowKey: string;
signal: AbortSignal;
}): Promise<PreparedTriggerRuntime> => {
@@ -387,8 +386,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
jobId: request.jobId,
agentId: request.requestedAgentId,
toolsAllow: request.toolsAllow,
ownerSessionKey: request.ownerSessionKey,
ownerAccountId: request.ownerAccountId,
scheduledToolPolicy: request.scheduledToolPolicy,
signal: request.signal,
});
const entry: TriggerRuntimeCacheEntry = {
@@ -414,8 +412,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
agentId?: string;
script: string;
toolsAllow?: string[];
ownerSessionKey?: string;
ownerAccountId?: string;
scheduledToolPolicy?: ScheduledToolPolicyContext;
abortSignal?: AbortSignal;
wallClockMs: number;
maxToolCalls: number;
@@ -435,8 +432,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
const agentId = resolveTriggerAgentId(runtimeConfig, params.agentId);
const toolsAllowKey = JSON.stringify([
params.toolsAllow ?? null,
params.ownerSessionKey?.trim() || null,
params.ownerAccountId?.trim() || null,
params.scheduledToolPolicy ?? null,
]);
const runtime = await resolveCachedRuntime({
runtimeConfig,
@@ -444,8 +440,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
requestedAgentId: params.agentId,
agentId,
toolsAllow: params.toolsAllow,
ownerSessionKey: params.ownerSessionKey,
ownerAccountId: params.ownerAccountId,
scheduledToolPolicy: params.scheduledToolPolicy,
toolsAllowKey,
signal: evaluationScope.signal,
});
@@ -612,8 +607,7 @@ export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) {
state: unknown;
streamBatch?: string;
toolsAllow?: string[];
ownerSessionKey?: string;
ownerAccountId?: string;
scheduledToolPolicy?: ScheduledToolPolicyContext;
abortSignal?: AbortSignal;
}): Promise<CronTriggerEvaluationResult> => {
if (activeTriggerEvaluations >= MAX_CONCURRENT_TRIGGER_EVALS) {
@@ -640,8 +634,7 @@ export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) {
state: unknown;
streamBatch?: string;
toolsAllow?: string[];
ownerSessionKey?: string;
ownerAccountId?: string;
scheduledToolPolicy?: ScheduledToolPolicyContext;
timeoutSeconds?: number;
toolBudget?: number;
abortSignal?: AbortSignal;

View File

@@ -3,6 +3,7 @@ import type { FailoverReason } from "../agents/embedded-agent-helpers/types.js";
import type { EmbeddedAgentExecutionPhase } from "../agents/embedded-agent-runner/execution-phase.js";
import type { ChannelId } from "../channels/plugins/types.public.js";
import type { HookExternalContentSource } from "../security/external-content.js";
import type { CronScheduledToolPolicy } from "./scheduled-tool-policy.js";
import type { CronJobBase, CronPacing } from "./types-shared.js";
export type { CronPacing } from "./types-shared.js";
@@ -466,6 +467,8 @@ export type CronJob = CronJobBase<
/** Authenticated account that created this scheduled authority envelope. */
accountId?: string;
};
/** Server-authored provenance for requester-scoped scheduled tool authority. */
scheduledToolPolicy?: CronScheduledToolPolicy;
trigger?: CronTrigger;
state: CronJobState;
};
@@ -479,7 +482,10 @@ export type CronStoreFile = {
type CronJobStateInput = Partial<Omit<CronJobState, "streamSourceIdentity">>;
/** Create input accepted by cron APIs before id/timestamps/state are assigned. */
export type CronJobCreate = Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs" | "state"> & {
export type CronJobCreate = Omit<
CronJob,
"id" | "createdAtMs" | "updatedAtMs" | "state" | "scheduledToolPolicy"
> & {
/** Internal callers can reserve a durable id before creation; public cron.add omits this. */
id?: string;
state?: CronJobStateInput;
@@ -498,6 +504,7 @@ export type CronJobPatch = Partial<
| "declarationKey"
| "displayName"
| "owner"
| "scheduledToolPolicy"
| "pacing"
>
> & {

View File

@@ -184,7 +184,7 @@ export class McpLoopbackToolCache {
// Unset (full scope) must never share a cache row with an empty
// allowlist (deny-all), so the marker distinguishes presence.
params.toolsAllow ? `allow:${[...new Set(params.toolsAllow)].toSorted().join(",")}` : "",
params.scheduledToolPolicy?.ownerSessionKey ?? "",
JSON.stringify(params.scheduledToolPolicy ?? null),
params.nodeExecAllowed === true ? "node-exec" : "",
params.execSession?.execHost ?? "",
params.execSession?.execSecurity ?? "",

View File

@@ -26,6 +26,7 @@ import { resolveCronDeliveryPlan, sendCronAnnouncePayloadStrict } from "../cron/
import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
import { resolveCronJobBoundSessionKeys } from "../cron/job-session-bindings.js";
import { toPublicCronJob } from "../cron/public-job.js";
import { resolveCronScheduledToolPolicy } from "../cron/scheduled-tool-policy.js";
import { CronService, type CronEvent } from "../cron/service.js";
import {
abortActiveCronTaskRuns,
@@ -606,8 +607,11 @@ export function buildGatewayCronService(params: {
state,
streamBatch,
toolsAllow: job.payload.toolsAllow,
ownerSessionKey: job.owner?.sessionKey,
ownerAccountId: job.owner?.accountId,
scheduledToolPolicy: resolveCronScheduledToolPolicy({
toolsAllow: job.payload.toolsAllow,
scheduledToolPolicy: job.scheduledToolPolicy,
owner: job.owner,
}),
abortSignal,
}),
}
@@ -838,8 +842,11 @@ export function buildGatewayCronService(params: {
state: job.state.triggerState,
streamBatch,
toolsAllow: job.payload.toolsAllow,
ownerSessionKey: job.owner?.sessionKey,
ownerAccountId: job.owner?.accountId,
scheduledToolPolicy: resolveCronScheduledToolPolicy({
toolsAllow: job.payload.toolsAllow,
scheduledToolPolicy: job.scheduledToolPolicy,
owner: job.owner,
}),
timeoutSeconds: job.payload.timeoutSeconds,
toolBudget: job.payload.toolBudget,
abortSignal,

View File

@@ -12,6 +12,7 @@ import {
} from "../../config/sessions.js";
import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { CronScheduledToolPolicy } from "../../cron/scheduled-tool-policy.js";
import type { PluginHookSessionEndReason } from "../../plugins/hook-types.js";
import {
AGENT_HARNESS_MODEL_RUN_FORBIDDEN_MESSAGE,
@@ -38,8 +39,7 @@ export type RestoredCronContinuation = {
thinking?: string;
toolsAllow?: string[];
toolsAllowIsDefault?: boolean;
ownerSessionKey?: string;
ownerAccountId?: string;
scheduledToolPolicy?: CronScheduledToolPolicy;
cliSessionBindingFacts?: {
extraSystemPromptStatic?: string;
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";

View File

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

View File

@@ -20,6 +20,7 @@ import {
} from "../../config/sessions/session-accessor.js";
import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { normalizeCronScheduledToolPolicy } from "../../cron/scheduled-tool-policy.js";
import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { recordSessionCreated } from "../../sessions/session-state-events.js";
@@ -227,11 +228,12 @@ export async function persistAgentSessionPhase(params: {
...(freshEntry.thinkingLevel ? { thinking: freshEntry.thinkingLevel } : {}),
...(marker.toolsAllow !== undefined ? { toolsAllow: [...marker.toolsAllow] } : {}),
...(marker.toolsAllowIsDefault === true ? { toolsAllowIsDefault: true } : {}),
...(marker.ownerSessionKey?.trim()
? { ownerSessionKey: marker.ownerSessionKey.trim() }
: {}),
...(marker.ownerAccountId?.trim()
? { ownerAccountId: marker.ownerAccountId.trim() }
...(normalizeCronScheduledToolPolicy(marker.scheduledToolPolicy)
? {
scheduledToolPolicy: normalizeCronScheduledToolPolicy(
marker.scheduledToolPolicy,
),
}
: {}),
...(marker.cliSessionBindingFacts
? { cliSessionBindingFacts: { ...marker.cliSessionBindingFacts } }

View File

@@ -1,3 +1,8 @@
import {
createAccountCronScheduledToolPolicy,
createTrustedCronScheduledToolPolicy,
type CronScheduledToolPolicy,
} from "../../cron/scheduled-tool-policy.js";
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";
@@ -26,6 +31,27 @@ export function readCronCallerScope(
};
}
/** Converts the authenticated gateway caller into server-only scheduled authority provenance. */
export function resolveCronScheduledToolPolicyForCaller(
callerScope: CronCallerScope | undefined,
): CronScheduledToolPolicy {
if (!callerScope) {
return createTrustedCronScheduledToolPolicy();
}
const policy = callerScope.sessionKey
? createAccountCronScheduledToolPolicy({
ownerSessionKey: callerScope.sessionKey,
ownerAccountId: callerScope.accountId,
})
: undefined;
if (!policy) {
// An agent-runtime caller cannot be promoted to operator authority merely
// because its signed runtime envelope omitted a session identity.
throw new TypeError("agent-runtime cron mutations require an authenticated session identity");
}
return policy;
}
function resolveCronJobEffectiveAgentId(job: CronJob, defaultAgentId?: string): string {
return normalizeAgentId(job.agentId ?? defaultAgentId ?? DEFAULT_AGENT_ID);
}

View File

@@ -60,6 +60,7 @@ import {
cronJobMatchesCallerScope,
cronPatchSessionRefsMatchCaller,
readCronCallerScope,
resolveCronScheduledToolPolicyForCaller,
type CronCallerScope,
} from "./cron-caller-scope.js";
import { isCronInvalidRequestError } from "./cron-error-classification.js";
@@ -746,6 +747,9 @@ export const cronHandlers: GatewayRequestHandlers = {
callerScope,
defaultAgentId: context.cron.getDefaultAgentId(),
}),
...(cronJobUsesToolRuntime(jobCreate)
? { scheduledToolPolicy: resolveCronScheduledToolPolicyForCaller(callerScope) }
: {}),
});
} catch (err) {
if (
@@ -901,38 +905,47 @@ export const cronHandlers: GatewayRequestHandlers = {
}
let job: Awaited<ReturnType<typeof context.cron.update>>;
try {
job = await context.cron.updateWithPrecondition(jobId, patch, async (lockedJob) => {
if (
!cronJobMatchesCallerScope({
job: lockedJob,
callerScope,
job = await context.cron.updateWithPrecondition(
jobId,
patch,
async (lockedJob) => {
if (
!cronJobMatchesCallerScope({
job: lockedJob,
callerScope,
defaultAgentId: context.cron.getDefaultAgentId(),
})
) {
throw new Error(`unknown cron job id: ${jobId}`);
}
if (p.expectedConfigRevision !== undefined) {
const actualConfigRevision = resolveCronJobConfigRevision(lockedJob);
if (actualConfigRevision !== p.expectedConfigRevision) {
throw new CronJobConfigRevisionConflictError(
p.expectedConfigRevision,
actualConfigRevision,
);
}
}
const nextJob = await assertValidCronUpdatePatch({
cfg,
defaultAgentId: context.cron.getDefaultAgentId(),
})
) {
throw new Error(`unknown cron job id: ${jobId}`);
}
if (p.expectedConfigRevision !== undefined) {
const actualConfigRevision = resolveCronJobConfigRevision(lockedJob);
if (actualConfigRevision !== p.expectedConfigRevision) {
throw new CronJobConfigRevisionConflictError(
p.expectedConfigRevision,
actualConfigRevision,
currentJob: lockedJob,
patch,
});
if (
cronPatchTouchesToolRuntime(patch) &&
requiresExplicitAgentRuntimeToolsAllow({ job: nextJob, callerScope })
) {
throw new TypeError(
"agent-runtime tool jobs require an explicit payload.toolsAllow cap",
);
}
}
const nextJob = await assertValidCronUpdatePatch({
cfg,
defaultAgentId: context.cron.getDefaultAgentId(),
currentJob: lockedJob,
patch,
});
if (
cronPatchTouchesToolRuntime(patch) &&
requiresExplicitAgentRuntimeToolsAllow({ job: nextJob, callerScope })
) {
throw new TypeError("agent-runtime tool jobs require an explicit payload.toolsAllow cap");
}
});
},
cronPatchTouchesToolRuntime(patch)
? { scheduledToolPolicy: resolveCronScheduledToolPolicyForCaller(callerScope) }
: undefined,
);
} catch (err) {
if (err instanceof CronJobConfigRevisionConflictError) {
respond(

View File

@@ -123,6 +123,7 @@ function createCronContext(currentJobs?: CronJob | CronJob[]) {
id: string,
patch: Partial<CronJob>,
precondition: (job: CronJob, nowMs: number) => void | Promise<void>,
_opts?: unknown,
) => {
const job = jobs.find((candidate) => candidate.id === id);
if (!job) {
@@ -1107,6 +1108,12 @@ describe("cron method validation", () => {
accountId: "default",
});
const options = requireRecord(context.cron.add.mock.calls[0]?.[1], "cron.add options");
expect(options.scheduledToolPolicy).toEqual({
version: 1,
mode: "account",
ownerSessionKey: "agent:ops:main",
ownerAccountId: "default",
});
const matchesExisting = options.matchesExisting as ((job: CronJob) => boolean) | undefined;
expect(matchesExisting?.(createCronJob({ agentId: "ops" }))).toBe(true);
expect(matchesExisting?.(createCronJob({ agentId: "worker" }))).toBe(false);
@@ -1235,6 +1242,7 @@ describe("cron method validation", () => {
expect(requireCronAddPayload(context).owner).toEqual(owner);
const options = requireRecord(context.cron.add.mock.calls[0]?.[1], "cron.add options");
expect(options.scheduledToolPolicy).toEqual({ version: 1, mode: "trusted" });
const matchesExisting = options.matchesExisting as ((job: CronJob) => boolean) | undefined;
expect(matchesExisting?.(createCronJob({ owner }))).toBe(true);
expect(
@@ -1562,6 +1570,7 @@ describe("cron method validation", () => {
);
expect(context.cron.update).toHaveBeenCalledWith("cron-1", { enabled: false });
expect(context.cron.updateWithPrecondition.mock.calls[0]?.[3]).toBeUndefined();
expectCronSuccess(respond);
});
@@ -1602,6 +1611,37 @@ describe("cron method validation", () => {
expectCronSuccess(respond);
});
it("passes authenticated provenance for an explicit agent-runtime tool edit", async () => {
const { context, respond } = await invokeCronUpdate(
{
id: "cron-1",
patch: {
payload: { kind: "agentTurn", message: "updated", toolsAllow: ["write"] },
},
},
createCronJob({
agentId: "ops",
owner: {
agentId: "ops",
sessionKey: "agent:ops:main",
accountId: "default",
},
payload: { kind: "agentTurn", message: "legacy", toolsAllow: ["read"] },
}),
{ client: callerClient("ops") },
);
expect(context.cron.updateWithPrecondition.mock.calls[0]?.[3]).toEqual({
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:ops:main",
ownerAccountId: "default",
},
});
expectCronSuccess(respond);
});
it("allows cron.update to clear a display name", async () => {
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { displayName: null } },

View File

@@ -26,7 +26,12 @@ type CreateOpenClawCodingToolsArg = {
workspaceDir?: string;
cwd?: string;
wrapBeforeToolCallHook?: boolean;
scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string };
scheduledToolPolicy?: {
version: 1;
mode: "account";
ownerSessionKey: string;
ownerAccountId: string;
};
};
type LazyExecToolDefaults = {
@@ -162,6 +167,8 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
excludeToolNames: ["read", "edit", "apply_patch", "exec", "process"],
mediatedToolNames: ["write"],
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:qa-channel:group:ops",
ownerAccountId: "default",
},
@@ -177,6 +184,8 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
cwd: "/workspace/task",
wrapBeforeToolCallHook: false,
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:qa-channel:group:ops",
ownerAccountId: "default",
},

View File

@@ -61,6 +61,8 @@ describe("resolveWorkerToolAuthority", () => {
senderId: "guest",
toolsAllow: ["read", "write", "exec"],
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:whatsapp:group:team",
ownerAccountId: "default",
},
@@ -89,6 +91,8 @@ describe("resolveWorkerToolAuthority", () => {
messageProvider: "whatsapp",
toolsAllow: ["write"],
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:whatsapp:group:team",
ownerAccountId: "default",
},

View File

@@ -23,6 +23,8 @@ describe("assertSupportedTurn", () => {
},
toolsAllow: ["write"],
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
},

View File

@@ -403,7 +403,7 @@ describe("scripts/test-docker-all scheduler", () => {
const summary = JSON.parse(readFileSync(path.join(logDir, "summary.json"), "utf8"));
expect(summary.status).toBe("passed");
expect(summary.lanes).toEqual([]);
expect(summary.omittedUnsupportedLanes).toHaveLength(11);
expect(summary.omittedUnsupportedLanes).toHaveLength(12);
expect(summary.omittedUnsupportedLanes).toContain("published-upgrade-survivor");
expect(summary.omittedUnsupportedLanes).toContain(
"published-upgrade-survivor-versioned-runtime-deps",

View File

@@ -795,6 +795,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
"published-upgrade-survivor-2026.4.29-tilde-log-path",
"published-upgrade-survivor-2026.4.29-meeting-transcripts-sqlite",
"published-upgrade-survivor-2026.4.29-versioned-runtime-deps",
"published-upgrade-survivor-2026.4.29-cron-scheduled-authority",
]);
});
@@ -832,6 +833,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
expect(plan.omittedUnsupportedLanes).toEqual([
"published-upgrade-survivor-2026.6.11-acpx-openclaw-tools-bridge",
"published-upgrade-survivor-2026.6.11-meeting-transcripts-sqlite",
"published-upgrade-survivor-2026.6.11-cron-scheduled-authority",
]);
});
@@ -870,6 +872,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
expect(plan.omittedUnsupportedLanes).toEqual([
"published-upgrade-survivor-2026.6.11-acpx-openclaw-tools-bridge",
"published-upgrade-survivor-2026.6.11-meeting-transcripts-sqlite",
"published-upgrade-survivor-2026.6.11-cron-scheduled-authority",
]);
});
@@ -885,7 +888,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
});
expect(plan.lanes).toEqual([]);
expect(plan.omittedUnsupportedLanes).toHaveLength(11);
expect(plan.omittedUnsupportedLanes).toHaveLength(12);
expect(plan.omittedUnsupportedLanes).toContain("published-upgrade-survivor-2026.6.11");
expect(plan.omittedUnsupportedLanes).toContain(
"published-upgrade-survivor-2026.6.11-versioned-runtime-deps",
@@ -930,7 +933,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
});
expect(plan.lanes.map((lane) => lane.name)).toEqual(["plugin-binding-command-escape"]);
expect(plan.omittedUnsupportedLanes).toHaveLength(11);
expect(plan.omittedUnsupportedLanes).toHaveLength(12);
expect(plan.omittedUnsupportedLanes).toContain("published-upgrade-survivor");
expect(plan.omittedUnsupportedLanes).toContain(
"published-upgrade-survivor-versioned-runtime-deps",
@@ -1066,6 +1069,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
"published-upgrade-survivor-2026.4.29-tilde-log-path",
"published-upgrade-survivor-2026.4.29-meeting-transcripts-sqlite",
"published-upgrade-survivor-2026.4.29-versioned-runtime-deps",
"published-upgrade-survivor-2026.4.29-cron-scheduled-authority",
"published-upgrade-survivor-2026.4.22",
"published-upgrade-survivor-2026.4.22-acpx-openclaw-tools-bridge",
"published-upgrade-survivor-2026.4.22-feishu-channel",
@@ -1076,6 +1080,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
"published-upgrade-survivor-2026.4.22-tilde-log-path",
"published-upgrade-survivor-2026.4.22-meeting-transcripts-sqlite",
"published-upgrade-survivor-2026.4.22-versioned-runtime-deps",
"published-upgrade-survivor-2026.4.22-cron-scheduled-authority",
"published-upgrade-survivor-2026.4.21",
"published-upgrade-survivor-2026.4.21-feishu-channel",
"published-upgrade-survivor-2026.4.21-bootstrap-persona",
@@ -1085,6 +1090,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
"published-upgrade-survivor-2026.4.21-tilde-log-path",
"published-upgrade-survivor-2026.4.21-meeting-transcripts-sqlite",
"published-upgrade-survivor-2026.4.21-versioned-runtime-deps",
"published-upgrade-survivor-2026.4.21-cron-scheduled-authority",
"published-upgrade-survivor-2026.3.13",
"published-upgrade-survivor-2026.3.13-feishu-channel",
"published-upgrade-survivor-2026.3.13-bootstrap-persona",
@@ -1094,6 +1100,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
"published-upgrade-survivor-2026.3.13-tilde-log-path",
"published-upgrade-survivor-2026.3.13-meeting-transcripts-sqlite",
"published-upgrade-survivor-2026.3.13-versioned-runtime-deps",
"published-upgrade-survivor-2026.3.13-cron-scheduled-authority",
]);
});