mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:41:38 +00:00
fix(cron): migrate scheduled authority provenance
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -197,6 +197,8 @@ describe("resolveConversationCapabilityProfile", () => {
|
||||
const scheduled = resolveConversationCapabilityProfile({
|
||||
...baseParams,
|
||||
scheduledToolPolicy: {
|
||||
version: 1,
|
||||
mode: "account",
|
||||
ownerSessionKey: "agent:main:whatsapp:group:team",
|
||||
ownerAccountId: "default",
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
125
src/commands/doctor/cron/scheduled-tool-policy-migration.test.ts
Normal file
125
src/commands/doctor/cron/scheduled-tool-policy-migration.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
109
src/commands/doctor/cron/scheduled-tool-policy-migration.ts
Normal file
109
src/commands/doctor/cron/scheduled-tool-policy-migration.ts
Normal 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" };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } }
|
||||
: {}),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
46
src/cron/scheduled-tool-policy.test.ts
Normal file
46
src/cron/scheduled-tool-policy.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
86
src/cron/scheduled-tool-policy.ts
Normal file
86
src/cron/scheduled-tool-policy.ts
Normal 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;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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" } }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
> & {
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -23,6 +23,8 @@ describe("assertSupportedTurn", () => {
|
||||
},
|
||||
toolsAllow: ["write"],
|
||||
scheduledToolPolicy: {
|
||||
version: 1,
|
||||
mode: "account",
|
||||
ownerSessionKey: "agent:main:discord:group:ops",
|
||||
ownerAccountId: "default",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user