diff --git a/src/cron/isolated-agent/run.message-tool-policy.test.ts b/src/cron/isolated-agent/run.message-tool-policy.test.ts index 0607eee664e6..cfc61cc6abad 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createSourceDeliveryPlan } from "../../infra/outbound/source-delivery-plan.js"; import type { SkillSnapshot } from "../../skills/types.js"; +import { applyJobPatch } from "../service/jobs.js"; import type { CronDeliveryMode } from "../types.js"; import type { MutableCronSession } from "./run-session-state.js"; import { @@ -860,6 +861,45 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { expect(cliRun.toolsAllow).toBeUndefined(); }); + it("keeps a cron-tool default toolsAllow marker after a self-edit before CLI execution", async () => { + mockRunCronFallbackPassthrough(); + resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan()); + isCliProviderMock.mockReturnValue(true); + runCliAgentMock.mockResolvedValue({ + payloads: [{ text: "done" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }); + const job = makeMessageToolPolicyJob( + { mode: "announce", channel: "messagechat", to: "123" }, + { + kind: "agentTurn", + message: "send a message", + toolsAllow: ["read", "cron"], + toolsAllowIsDefault: true, + }, + ); + + applyJobPatch(job, { + payload: { + kind: "agentTurn", + message: "send a clearer message", + toolsAllow: ["read", "cron"], + }, + }); + + await runCronIsolatedAgentTurn({ + ...makeParams(), + job, + }); + + const cliRun = expectRecordFields( + getMockCallArg(runCliAgentMock, 0, 0, "CLI run"), + {}, + "CLI run params", + ); + expect(cliRun.toolsAllow).toBeUndefined(); + }); + it("keeps automatic exec completion notifications when announce delivery is active", async () => { mockRunCronFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan()); diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 757f0e72cf2c..3c661625976e 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -449,6 +449,34 @@ describe("applyJobPatch", () => { } }); + it("preserves the default toolsAllow flag when a self-edit echoes the default list", () => { + const job = createIsolatedAgentTurnJob("job-tools-default-echo", { + mode: "announce", + channel: "telegram", + }); + job.payload = { + kind: "agentTurn", + message: "do it", + toolsAllow: ["exec", "read"], + toolsAllowIsDefault: true, + }; + + applyJobPatch(job, { + payload: { + kind: "agentTurn", + message: "do it later", + toolsAllow: ["exec", "read"], + }, + }); + + expect(job.payload.kind).toBe("agentTurn"); + if (job.payload.kind === "agentTurn") { + expect(job.payload.message).toBe("do it later"); + expect(job.payload.toolsAllow).toEqual(["exec", "read"]); + expect(job.payload.toolsAllowIsDefault).toBe(true); + } + }); + it("clears agentTurn payload.toolsAllow when patch requests null", () => { const job = createIsolatedAgentTurnJob("job-tools-clear", { mode: "announce", diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 3c59bf5e91c4..7ae3acf004b4 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -963,12 +963,17 @@ function applyAgentTurnToolsAllowPatch( ): void { if (Array.isArray(patch.toolsAllow)) { payload.toolsAllow = patch.toolsAllow; - // Same-kind edits keep the marker only when the default list is unchanged; - // kind replacements carry the cron-tool-stamped marker into persistence. - if ( - patch.toolsAllowIsDefault === true && - (!existing || (existing.toolsAllowIsDefault === true && toolsAllowEqual(existing, patch))) - ) { + // Same-kind edits keep the marker whenever the default-stamped list is + // unchanged — even when the patch omits toolsAllowIsDefault, because the + // cron tool's model-facing schema never sends it. Dropping the marker on an + // echoed list silently reclassifies "default" as an explicit restriction, + // which fail-closes the next run on CLI backends that cannot enforce + // runtime toolsAllow. Kind replacements (no existing payload) still require + // the cron-tool-stamped marker on the patch itself. + const keepDefaultMarker = existing + ? existing.toolsAllowIsDefault === true && toolsAllowEqual(existing, patch) + : patch.toolsAllowIsDefault === true; + if (keepDefaultMarker) { payload.toolsAllowIsDefault = true; } else { delete payload.toolsAllowIsDefault;