fix: require declared scheduled write adapters (#151064)

Scheduled message mutations now require their saved provider/account scope and an adapter-declared live-authority contract, without changing direct or interactive actions.

Fixes #151060. Refs #140978.
This commit is contained in:
Shakker 2026-09-17 20:10:32 +01:00 • committed by GitHub
parent f6d8e23461
commit 2bbfc3c8b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 38 additions and 14 deletions

View file

@ -699,8 +699,8 @@ An opted-in adapter must honor the existing
This optional field keeps older adapters source-compatible. An omitted or empty
declaration leaves newly enabled scheduled actions denied. Existing bundled
provider-owned message-management paths keep their admission rules. To support
the new installed-plugin path,
provider-owned interactive paths keep their admission rules. To support the new
installed-plugin path,
upgrade OpenClaw and the plugin, implement the request and retry checks above,
declare only the covered actions, and load the updated registration. Existing
direct-operator and interactive actions retain their admission rules. Upgrading

View file

@ -540,15 +540,6 @@ function prepareScheduledMessageWriteContext(
if (!policy || !ctx.messageActionAuthorization?.scheduled) {
return undefined;
}
if (
policy === "provider" &&
prepared.enforcement.kind === "provider-owned" &&
prepared.enforcement.pluginTrust === "bundled" &&
!prepared.plugin.actions?.writeAuthorityActions?.includes(action)
) {
// Retain existing bundled provider admission until its adapter opts into this fence.
return undefined;
}
const accountId =
ctx.accountId ?? resolveChannelDefaultAccountId({ plugin: prepared.plugin, cfg: ctx.cfg });
const access = resolveScheduledMessageActionAccess({

View file

@ -1480,16 +1480,49 @@ describe("CLI message authority integration", () => {
]);
});
it("preserves bundled provider-owned scheduled pins without a write declaration", async () => {
it("keeps bundled interactive pins but denies undeclared scheduled writes", async () => {
registerChannelPlugins({ discordOrigin: "bundled" });
const actions = expectDefined(registeredDiscordActions, "bundled Discord actions");
delete actions.writeAuthorityActions;
const turn = await createTurn("discord", { scheduledPolicy: accountScheduledPolicy });
const interactiveTurn = await createTurn("discord");
expectSuccess(
await turn.call({ ...messageTarget, action: "pin", target: `channel:${discordSibling}` }),
await interactiveTurn.call({
...messageTarget,
action: "pin",
target: `channel:${discordSibling}`,
}),
);
const requestCount = requests.length;
const mismatchedOriginPolicy: ScheduledToolPolicyContext = {
...accountScheduledPolicy,
ownerSessionKey: `agent:main:slack:channel:${channels.slack.current}`,
ownerOrigin: { kind: "external", channel: "slack" },
};
const mismatchedOriginTurn = await createTurn("discord", {
scheduledPolicy: mismatchedOriginPolicy,
});
expectDenied(
await mismatchedOriginTurn.call({
...messageTarget,
action: "pin",
target: `channel:${discordSibling}`,
}),
/matching recorded creator origin/,
);
const undeclaredTurn = await createTurn("discord", {
scheduledPolicy: accountScheduledPolicy,
});
expectDenied(
await undeclaredTurn.call({
...messageTarget,
action: "pin",
target: `channel:${discordSibling}`,
}),
/write authorization support/,
);
expect(requests).toHaveLength(requestCount);
expect(requests.filter((request) => request.method !== "GET")).toEqual([
expect.objectContaining({
method: "PUT",