openclaw/test/cron-message-read.integration.test.ts
Shakker 9ee5c26f40
fix: retain the native requester for scheduled Discord edits (#150308)
Retain the native requester for account-scheduled Discord channel edits, reauthorize executable changes, and stop later actions from revoked runs.

Refs #140978.
2026-09-17 10:53:36 +01:00

1023 lines
43 KiB
TypeScript

import { readFile, mkdir, writeFile } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import path from "node:path";
import { json as readJson } from "node:stream/consumers";
import { expectDefined } from "@openclaw/normalization-core";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildAnthropicCliBackend } from "../extensions/anthropic/api.js";
import { discordPlugin } from "../extensions/discord/api.js";
import { refreshPreparedModelRuntimeSnapshots } from "../src/agents/prepared-model-runtime.js";
import { resetPreparedModelRuntimeSnapshotsForTest } from "../src/agents/prepared-model-runtime.test-support.js";
import * as runtimePlugins from "../src/agents/runtime-plugins.js";
import { AUTOMATIONS_TOOL_NAME } from "../src/agents/tools/automations-tool-name.js";
import { getReplyFromConfig } from "../src/auto-reply/reply/get-reply.js";
import {
clearRuntimeConfigSnapshot,
getRuntimeConfig,
setRuntimeConfigSnapshot,
} from "../src/config/config.js";
import { replaceSessionEntry } from "../src/config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../src/config/types.openclaw.js";
import {
getSuspensionVisibleCronTaskRunCount,
waitForActiveCronTaskRuns,
} from "../src/cron/service/active-run-cancellation.js";
import { loadCronStore, resolveCronJobsStorePathFromConfig } from "../src/cron/store.js";
import type { CronJobCreate } from "../src/cron/types.js";
import * as mcpHttpHandlers from "../src/gateway/mcp-http.handlers.js";
import { closeMcpLoopbackServer, ensureMcpLoopbackServer } from "../src/gateway/mcp-http.js";
import { getActiveMcpLoopbackRuntime } from "../src/gateway/mcp-http.loopback-runtime.js";
import {
disconnectGatewayClient,
getGatewayE2ePortBlock,
startGatewayWithClient,
} from "../src/gateway/test-helpers.e2e.js";
import { buildMockOpenAiResponsesProvider } from "../src/gateway/test-openai-responses-model.js";
import { formatErrorMessage } from "../src/infra/errors.js";
import { redactToolPayloadText } from "../src/logging/redact.js";
import { createPluginRegistry } from "../src/plugins/registry.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../src/plugins/runtime.js";
import type { PluginRuntime } from "../src/plugins/runtime/types.js";
import { createPluginRecord } from "../src/plugins/status.test-fixtures.js";
import { buildAgentPeerSessionKey } from "../src/routing/session-key.js";
import {
closeOpenClawStateDatabaseAsync,
closeOpenClawStateDatabaseForTest,
} from "../src/state/openclaw-state-db.js";
import { createAccountOwnedScheduledJob } from "./helpers/cron/account-owned-scheduled-job.js";
import { createDeferred, withTestTimeout } from "./helpers/promise.js";
import { runQaGatewayFixture } from "./helpers/qa-gateway-cleanup.js";
import { createScheduledMessageReadModel } from "./helpers/scheduled-message-read-model.js";
import { useAutoCleanupTempDirTracker } from "./helpers/temp-dir.js";
// Attached devices are outside these scheduled message journeys.
vi.mock("../src/agents/node-exec-availability.js", () => ({
loadNodeExecAvailability: async () => ({ cacheKey: "no-nodes", isAvailable: () => false }),
}));
const channelId = "100000000000000003";
const guildId = "100000000000000001";
const messageId = "100000000000000020";
const providerMessage = "A synthetic message read by the scheduled turn.";
const providerTopic = "Topic set by the scheduled operator job.";
const modelId = "claude-sonnet-4-6";
const modelRef = `anthropic/${modelId}`;
const providerToken = "synthetic-scheduled-discord-token";
const embeddedModelId = "scheduled-message-read-fixture";
const modelToken = "synthetic-scheduled-model-token";
const nativeRequesterId = "100000000000000009";
const originalCreatorId = "100000000000000008";
const channelManagementRole = "100000000000000006";
const acceptedTopic = "Topic authorized for the recorded requester";
const deniedTopic = "Topic requiring a fresh permission check";
type ScheduledMessageScenario = {
title: string;
runtime: "claude-cli" | "openclaw";
creator?: "trusted" | "account" | "native";
action: "read" | "channel-info" | "channel-edit";
disableBeforeResponse?: 200 | 429;
};
const scenarios: ScheduledMessageScenario[] = [
{ title: "claude-cli/read", runtime: "claude-cli", action: "read" },
{ title: "openclaw/channel-info", runtime: "openclaw", action: "channel-info" },
{
title: "edits Discord through a trusted operator-created cron job and its generated MCP grant",
runtime: "claude-cli",
action: "channel-edit",
},
{
title: "blocks a Discord 429 retry after cron.update disables the executing job",
runtime: "claude-cli",
action: "channel-edit",
disableBeforeResponse: 429,
},
{
title: "preserves an accepted Discord edit after cron.update disables the executing job",
runtime: "claude-cli",
action: "channel-edit",
disableBeforeResponse: 200,
},
{ title: "openclaw/channel-edit", runtime: "openclaw", action: "channel-edit" },
{
title: "account/claude-cli/channel-info",
runtime: "claude-cli",
creator: "account",
action: "channel-info",
},
{
title: "account/openclaw/read",
runtime: "openclaw",
creator: "account",
action: "read",
},
{
title: "creates an account job from requester B and checks B's current Discord permissions",
runtime: "claude-cli",
creator: "native",
action: "channel-edit",
},
];
// Uses the maintained control/JSONL child protocol from anthropic/cli-process.test.ts.
// Only the model's decisions and Discord responses are synthetic. This child reads
// the real CLI-generated MCP config and credentials; it creates no authority.
const PROTOCOL_CHILD = String.raw`
import { readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline";
const send = (message) => process.stdout.write(JSON.stringify(message) + "\n");
const argument = (name) => process.argv[process.argv.indexOf(name) + 1];
const configPath = argument("--mcp-config");
if (!process.argv.includes("--mcp-config") || !configPath) {
throw new Error("The real CLI runner did not generate an MCP config.");
}
const server = JSON.parse(readFileSync(configPath, "utf8")).mcpServers.openclaw;
const url = new URL(server.url);
if (url.hostname !== "127.0.0.1" || url.protocol !== "http:" || url.pathname !== "/mcp") {
throw new Error("Expected the task-owned loopback MCP endpoint.");
}
const headers = Object.fromEntries(Object.entries(server.headers).map(([name, value]) => [
name,
value.replace(/\$\{([^}]+)\}/g, (_, key) => {
if (!process.env[key]) throw new Error("Missing generated MCP credential: " + key);
return process.env[key];
}),
]));
let sequence = 0;
const rpc = async (method, params) => {
const response = await fetch(url, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: ++sequence, method, params }),
signal: AbortSignal.timeout(30000),
});
if (!response.ok) throw new Error("MCP HTTP status " + response.status);
return await response.json();
};
createInterface({ input: process.stdin }).on("line", (line) => {
const message = JSON.parse(line);
if (message.type === "control_request") {
send({ type: "control_response", response: {
subtype: "success", request_id: message.request_id, response: {},
} });
} else if (message.type === "user") {
void (async () => {
const sessionId = message.session_id || argument("--session-id");
send({ type: "system", subtype: "init", session_id: sessionId, tools: [] });
const createJob = process.env.OPENCLAW_SCHEDULED_CREATE_JOB;
if (!createJob) {
const gap = await fetch(process.env.OPENCLAW_SCHEDULED_READ_CLOCK_URL, { method: "POST" });
if (!gap.ok) throw new Error("Could not advance the scheduled-read fixture clock.");
}
const listed = await rpc("tools/list");
const reply = await rpc("tools/call", {
name: createJob ? "${AUTOMATIONS_TOOL_NAME}" : "message",
arguments: createJob
? { action: "add", job: JSON.parse(createJob) }
: JSON.parse(process.env.OPENCLAW_SCHEDULED_READ_ARGUMENTS),
});
const followupArguments = createJob
? undefined
: process.env.OPENCLAW_SCHEDULED_MESSAGE_FOLLOWUP_ARGUMENTS;
const followupReply = followupArguments
? await rpc("tools/call", { name: "message", arguments: JSON.parse(followupArguments) })
: undefined;
const resultFile = createJob ? "./created-job.json" : "./mcp-result.json";
writeFileSync(new URL(resultFile, import.meta.url), JSON.stringify({ listed, reply, followupReply }));
send({ type: "result", subtype: "success", is_error: false,
result: JSON.stringify(reply), session_id: sessionId,
duration_ms: 1, duration_api_ms: 1, num_turns: 1, total_cost_usd: 0,
usage: {}, modelUsage: {}, permission_denials: [],
});
if (createJob) process.stdin.destroy();
})().catch((error) => {
process.stderr.write(String(error) + "\n");
process.exitCode = 1;
process.stdin.destroy();
});
}
});
`;
type McpResponse = {
result?: {
tools?: Array<{ name: string; inputSchema?: unknown }>;
content?: Array<{ type: string; text?: string }>;
isError?: boolean;
};
error?: unknown;
};
function describeFixtureError(error: unknown): string {
const cause = error instanceof Error ? error.cause : undefined;
return redactToolPayloadText(
cause === undefined
? formatErrorMessage(error)
: `${formatErrorMessage(error)}; cause: ${formatErrorMessage(cause)}`,
);
}
async function closeServer(server: Server): Promise<void> {
server.closeAllConnections();
if (server.listening) {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
}
describe("scheduled message actions", () => {
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
it.each(scenarios)("$title", { timeout: 60_000 }, async (scenario) => {
const { runtime, action, disableBeforeResponse, creator = "trusted" } = scenario;
const nativeCreator = creator === "native";
// Local creator turns use the default account identity, independent of provider defaults.
const creatorAccountId = nativeCreator ? "work" : "default";
const creatorSessionKey = nativeCreator
? buildAgentPeerSessionKey({
agentId: "main",
channel: "discord",
accountId: creatorAccountId,
peerKind: "channel",
peerId: channelId,
})
: "agent:main:scheduled-account-creator";
const expectedTopic = nativeCreator ? acceptedTopic : providerTopic;
const expectedProviderToken =
creator === "account" ? "synthetic-creator-discord-token" : providerToken;
const actionParams = {
action,
channel: "discord",
...(creator === "trusted" ? { accountId: creatorAccountId } : {}),
...(action === "read"
? { target: `channel:${channelId}`, limit: 1 }
: action === "channel-edit"
? { target: `channel:${channelId}`, topic: expectedTopic }
: { channelId }),
};
const expectedResult =
action === "read"
? {
ok: true,
channelId,
messages: [{ id: messageId, channel_id: channelId, content: providerMessage }],
}
: {
ok: true,
channel: {
id: channelId,
guild_id: guildId,
type: 0,
name: "scheduled-read",
...(action === "channel-edit" ? { topic: expectedTopic } : {}),
},
};
const assertToolResult = (text: string) => {
expect(JSON.parse(text)).toMatchObject(expectedResult);
};
const assertAccountToolSchema =
creator === "account"
? (schema: unknown) => {
const properties = isRecord(schema) ? schema.properties : undefined;
const actionSchema = isRecord(properties) ? properties.action : undefined;
const advertisedActions = isRecord(actionSchema) ? actionSchema.enum : undefined;
expect(advertisedActions).toContain(action);
expect(advertisedActions).not.toContain(action === "read" ? "channel-info" : "read");
}
: undefined;
const embeddedModel = createScheduledMessageReadModel({
modelId: embeddedModelId,
apiKey: modelToken,
actionParams,
assertToolResult,
assertToolSchema: assertAccountToolSchema,
});
const isolatedHome = expectDefined(process.env.OPENCLAW_TEST_HOME, "isolated test HOME");
const root = tempDirs.make("scheduled-message-read-", isolatedHome);
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const configPath = path.join(root, "openclaw.json");
const childPath = path.join(root, "claude.mjs");
const cleanup: Array<() => void | Promise<void>> = [];
const requests: Array<{ method: string; path: string; authorizationMatches: boolean }> = [];
const edits: unknown[] = [];
const heldPatch = disableBeforeResponse
? { entered: createDeferred(), release: createDeferred() }
: undefined;
// Job cancellation may terminate the child before it can persist the MCP reply.
const mcpHandlerSpy = disableBeforeResponse
? vi.spyOn(mcpHttpHandlers, "handleMcpJsonRpc")
: undefined;
const providerErrors: string[] = [];
const providerWork = new Set<Promise<void>>();
let requesterPermissions = 16n; // Discord MANAGE_CHANNELS.
let metadataControl: "pending" | "passed" = "pending";
const diagnostics = (result: unknown) =>
redactToolPayloadText(
JSON.stringify({
result,
metadataControl,
requests,
providerErrors,
model: embeddedModel.observation,
}),
);
const readObservedMcpResponse = async (method: "tools/list" | "tools/call") => {
const spy = expectDefined(mcpHandlerSpy, "MCP handler observer for a disabled job");
const index = spy.mock.calls.findIndex(
([request]) =>
request.message.method === method &&
request.hookContext?.workspaceDir === workspaceDir &&
(method === "tools/list" || request.message.params?.name === "message"),
);
const returned = expectDefined(spy.mock.results[index], `Scheduled MCP ${method} result`);
if (returned.type !== "return") {
throw new Error(`Scheduled MCP ${method} did not return a response promise.`);
}
return (await withTestTimeout(
returned.value,
45_000,
`Expected scheduled MCP ${method} response after job disable`,
)) as McpResponse;
};
await runQaGatewayFixture(
async () => {
await mkdir(workspaceDir, { recursive: true });
await writeFile(childPath, `#!${process.execPath}\n${PROTOCOL_CHILD}`, { mode: 0o700 });
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
vi.stubEnv("OPENCLAW_AGENT_DIR", undefined);
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(root, "claude-config"));
for (const key of [
"OPENCLAW_TEST_MINIMAL_GATEWAY",
"OPENCLAW_SKIP_BROWSER_CONTROL_SERVER",
"OPENCLAW_SKIP_CANVAS_HOST",
"OPENCLAW_SKIP_GMAIL_WATCHER",
]) {
vi.stubEnv(key, "1");
}
// Minimal mode suppresses channel startup; the skip flags would also
// remove channel credentials from the published runtime config.
vi.stubEnv("OPENCLAW_SKIP_CHANNELS", undefined);
vi.stubEnv("OPENCLAW_SKIP_PROVIDERS", undefined);
vi.stubEnv("OPENCLAW_GATEWAY_URL", undefined);
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", undefined);
const gatewayPort = await getGatewayE2ePortBlock();
const gatewayToken = "synthetic-scheduled-read-gateway-token";
vi.stubEnv("OPENCLAW_SCHEDULED_READ_ARGUMENTS", JSON.stringify(actionParams));
vi.stubEnv("OPENCLAW_SCHEDULED_CREATE_JOB", undefined);
vi.stubEnv(
"OPENCLAW_SCHEDULED_MESSAGE_FOLLOWUP_ARGUMENTS",
nativeCreator
? JSON.stringify({
action: "channel-edit",
channel: "discord",
target: `channel:${channelId}`,
topic: deniedTopic,
})
: undefined,
);
for (const key of [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
]) {
vi.stubEnv(key, undefined);
}
cleanup.push(() => clearRuntimeConfigSnapshot());
cleanup.push(() => closeOpenClawStateDatabaseForTest());
cleanup.push(() => closeOpenClawStateDatabaseAsync());
const provider = createServer((req, res) => {
const work = (async () => {
const url = new URL(req.url ?? "/", "http://fixture.invalid");
if (req.method === "POST" && url.pathname === "/scheduled-clock-gap") {
expect(runtime).toBe("claude-cli");
expect(metadataControl).toBe("passed");
const realNow = Date.now.bind(Date);
// The real CLI has its grant; model a pause beyond its former timeout-plus-grace TTL.
vi.spyOn(Date, "now").mockImplementation(() => realNow() + 120_000);
res.writeHead(200).end();
return;
}
if (req.method === "POST" && url.pathname === "/v1/responses") {
expect(runtime, "embedded model request uses the selected runtime").toBe("openclaw");
await embeddedModel.respond(req, res);
return;
}
const authorizationMatches =
req.headers.authorization === `Bot ${expectedProviderToken}`;
requests.push({ method: req.method ?? "", path: url.pathname, authorizationMatches });
expect(authorizationMatches, "Discord fixture authorization matches").toBe(true);
if (req.method === "PATCH" && url.pathname === `/api/v10/channels/${channelId}`) {
const edit: unknown = await readJson(req);
edits.push(edit);
expect(edit).toEqual({ topic: expectedTopic });
if (heldPatch && edits.length === 1) {
heldPatch.entered.resolve();
await heldPatch.release.promise;
if (disableBeforeResponse === 429) {
res
.writeHead(429, {
"content-type": "application/json",
"retry-after": "0.001",
})
.end(JSON.stringify({ message: "Rate limited", retry_after: 0.001 }));
return;
}
}
if (nativeCreator) {
// Each edit must use B's current permissions, independent of attribution A.
requesterPermissions = 0n;
}
res.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
id: channelId,
type: 0,
guild_id: guildId,
name: "scheduled-read",
topic: expectedTopic,
}),
);
return;
}
expect(req.method).toBe("GET");
let body: unknown =
url.pathname === `/api/v10/channels/${channelId}`
? { id: channelId, type: 0, guild_id: guildId, name: "scheduled-read" }
: url.pathname === `/api/v10/channels/${channelId}/messages`
? [
{
id: messageId,
channel_id: channelId,
content: providerMessage,
author: {
id: "100000000000000009",
username: "synthetic",
discriminator: "0",
},
timestamp: "2026-09-16T09:00:00.000Z",
type: 0,
attachments: [],
embeds: [],
},
]
: undefined;
if (nativeCreator && url.pathname === `/api/v10/guilds/${guildId}`) {
body = {
id: guildId,
owner_id: originalCreatorId,
roles: [
{ id: guildId, permissions: "0" },
{ id: channelManagementRole, permissions: requesterPermissions.toString() },
],
};
} else if (
nativeCreator &&
url.pathname === `/api/v10/guilds/${guildId}/members/${nativeRequesterId}`
) {
body = { user: { id: nativeRequesterId }, roles: [channelManagementRole] };
}
if (body === undefined) {
throw new Error(`Unexpected provider request: ${req.method} ${url.pathname}`);
}
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(body));
})().catch((error: unknown) => {
providerErrors.push(describeFixtureError(error));
res.writeHead(500).end();
});
providerWork.add(work);
void work.finally(() => providerWork.delete(work));
});
cleanup.push(async () => {
await closeServer(provider);
await Promise.all(providerWork);
});
await new Promise<void>((resolve, reject) => {
provider.once("error", reject);
provider.listen(0, "127.0.0.1", resolve);
});
const address = provider.address();
if (!address || typeof address === "string") {
throw new Error("Expected provider TCP address");
}
const providerOrigin = `http://127.0.0.1:${address.port}`;
vi.stubEnv("OPENCLAW_SCHEDULED_READ_CLOCK_URL", `${providerOrigin}/scheduled-clock-gap`);
const embedded = buildMockOpenAiResponsesProvider(`${providerOrigin}/v1`, embeddedModelId);
const selectedModelRef = runtime === "openclaw" ? embedded.modelRef : modelRef;
const cfg: OpenClawConfig = {
gateway: {
mode: "local",
port: gatewayPort,
auth: { mode: "token", token: gatewayToken },
controlUi: { enabled: false },
},
agents: {
ownership: "explicit",
entries: { main: {} },
defaults: {
workspace: workspaceDir,
skipBootstrap: true,
timeoutSeconds: 40,
model: { primary: selectedModelRef, fallbacks: [] },
models: {
[modelRef]: { agentRuntime: { id: "claude-cli" } },
[embedded.modelRef]: {
agentRuntime: { id: "openclaw" },
params: { transport: "sse", openaiWsWarmup: false },
},
},
thinkingDefault: "off",
},
},
models: {
providers: {
[embedded.providerId]: {
...embedded.config,
apiKey: modelToken,
request: { allowPrivateNetwork: true },
},
anthropic: {
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
apiKey: "synthetic-unused-model-key",
models: [
{
id: modelId,
name: "Synthetic Claude CLI model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8192,
},
],
},
},
},
tools: { allow: creator !== "trusted" ? ["message", "automations"] : ["message"] },
...(nativeCreator
? { commands: { ownerAllowFrom: [`discord:${nativeRequesterId}`] } }
: {}),
plugins: { allow: ["anthropic", "discord"], slots: { memory: "none" } },
channels: {
discord: {
enabled: true,
...(nativeCreator
? {
token: "synthetic-unused-default-token",
defaultAccount: "default",
accounts: {
default: { actions: { channels: false } },
work: { token: expectedProviderToken, actions: { channels: true } },
},
}
: creator === "account"
? {
defaultAccount: "other",
accounts: {
default: {
token: expectedProviderToken,
actions: {
messages: action === "read",
channelInfo: action === "channel-info",
},
},
other: {
token: "synthetic-other-discord-token",
actions: {
messages: action === "channel-info",
channelInfo: action === "read",
},
},
},
}
: { token: providerToken }),
groupPolicy: "allowlist",
guilds: { [guildId]: { channels: { "*": { enabled: true } } } },
},
},
cron: { enabled: false },
};
await writeFile(configPath, JSON.stringify(cfg));
setRuntimeConfigSnapshot(cfg, cfg);
if (nativeCreator) {
await replaceSessionEntry(
{ agentId: "main", sessionKey: creatorSessionKey },
{
sessionId: "native-creator-session",
updatedAt: Date.now(),
createdActor: { type: "human", source: "channel", id: originalCreatorId },
},
);
}
vi.stubEnv("DISCORD_API_URL", `${providerOrigin}/api/v10`);
await ensureMcpLoopbackServer(0);
cleanup.push(() => closeMcpLoopbackServer());
const mcpRuntime = expectDefined(getActiveMcpLoopbackRuntime(), "task-owned MCP runtime");
const mcpOrigin = `http://127.0.0.1:${mcpRuntime.port}`;
const realFetch = globalThis.fetch.bind(globalThis);
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>(async (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (
url.origin !== providerOrigin &&
url.origin !== mcpOrigin &&
url.origin !== `http://127.0.0.1:${gatewayPort}`
) {
throw new Error(`Unexpected fixture network destination: ${url.origin}`);
}
return await realFetch(input, init);
}),
);
const owner = createPluginRegistry({
logger: { info() {}, warn() {}, error() {}, debug() {} },
runtime: {} as PluginRuntime,
activateGlobalSideEffects: false,
});
for (const id of ["anthropic", "discord"]) {
const record = createPluginRecord({ id, origin: "global", trustedOfficialInstall: true });
owner.registry.plugins.push(record);
const api = owner.createApi(record, { config: cfg, registrationMode: "full" });
if (id === "discord") {
api.registerChannel({ plugin: { ...discordPlugin, status: undefined } });
} else {
const backend = buildAnthropicCliBackend();
api.registerCliBackend({
...backend,
config: { ...backend.config, command: childPath },
});
}
}
setActivePluginRegistry(owner.registry);
cleanup.push(() => resetPluginRuntimeStateForTest());
// Installed discovery is outside this fixture. Both maintained acquisition
// paths borrow the same real registrations while prepared-runtime ownership stays real.
vi.spyOn(runtimePlugins, "loadAgentRuntimePluginRegistryHandle").mockImplementation(
(_params, onPrimaryRegistry) => {
onPrimaryRegistry?.(owner.registry);
return owner.registry;
},
);
vi.spyOn(runtimePlugins, "acquireAgentRuntimePluginRegistry").mockResolvedValue({
registry: owner.registry,
primaryRegistry: owner.registry,
});
cleanup.push(() => resetPreparedModelRuntimeSnapshotsForTest());
const finished = createDeferred<Record<string, unknown>>();
const scheduledJob: { id?: string } = {};
const gateway = await startGatewayWithClient({
port: gatewayPort,
cfg,
configPath,
token: gatewayToken,
scopes: ["operator.admin"],
onEvent: (event) => {
if (
event.event === "cron" &&
isRecord(event.payload) &&
event.payload.action === "finished" &&
event.payload.jobId === scheduledJob.id
) {
finished.resolve(event.payload);
}
},
});
cleanup.push(async () => {
await runQaGatewayFixture(
() => disconnectGatewayClient(gateway.client),
() => gateway.server.close({ reason: "scheduled read fixture complete" }),
);
});
await gateway.server.startupSettled;
await refreshPreparedModelRuntimeSnapshots(getRuntimeConfig(), {
gatewayLifecycle: true,
catalogMode: "static",
});
const runtimeConfig = getRuntimeConfig();
const { fetchChannelInfoDiscord } = await import("../extensions/discord/runtime-api.js");
const metadata = await fetchChannelInfoDiscord(channelId, {
cfg: runtimeConfig,
accountId: creatorAccountId,
}).catch((error: unknown) => {
throw new Error(diagnostics({ metadataError: describeFixtureError(error) }));
});
expect(metadata, diagnostics(metadata)).toMatchObject({
id: channelId,
type: 0,
guild_id: guildId,
});
expect(requests, diagnostics(metadata)).toEqual([
{
method: "GET",
path: `/api/v10/channels/${channelId}`,
authorizationMatches: true,
},
]);
metadataControl = "passed";
// The direct transport control cannot satisfy the scheduled journey's evidence.
requests.length = 0;
const params = {
name: nativeCreator
? "Edit Discord for the recorded requester"
: `Scheduled Discord ${action}`,
agentId: "main",
enabled: true,
schedule: { kind: "every", everyMs: 86_400_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: {
kind: "agentTurn",
message: nativeCreator
? `Set Discord channel ${channelId} to topic ${acceptedTopic}, then try topic ${deniedTopic}, using account ${creatorAccountId}.`
: `Use message action ${action} for Discord channel ${channelId}${creator === "trusted" ? " with account default" : " without an accountId argument"}.`,
toolsAllow: ["message"],
},
delivery:
creator === "account"
? { mode: "none", channel: "discord", accountId: "other" }
: { mode: "none" },
} satisfies CronJobCreate;
let created: { id: string };
if (nativeCreator) {
vi.stubEnv("OPENCLAW_SCHEDULED_CREATE_JOB", JSON.stringify(params));
let producerReply: Awaited<ReturnType<typeof getReplyFromConfig>>;
try {
producerReply = await getReplyFromConfig(
{
Body: "Schedule these Discord channel topic changes for later.",
From: `discord:channel:${channelId}`,
To: `channel:${channelId}`,
Provider: "discord",
Surface: "discord",
AccountId: creatorAccountId,
SessionKey: creatorSessionKey,
ChatType: "channel",
NativeChannelId: channelId,
GroupSpace: guildId,
SenderId: nativeRequesterId,
MessageSid: "100000000000000021",
WasMentioned: true,
},
{ runId: "native-cron-creator" },
);
} finally {
vi.stubEnv("OPENCLAW_SCHEDULED_CREATE_JOB", undefined);
}
for (const payload of Array.isArray(producerReply) ? producerReply : [producerReply]) {
expect(payload?.isError, diagnostics(producerReply)).not.toBe(true);
}
const creation = JSON.parse(
await readFile(path.join(root, "created-job.json"), "utf8").catch((error: unknown) => {
throw new Error(
diagnostics({ producerReply, creationError: describeFixtureError(error) }),
);
}),
) as { listed: McpResponse; reply: McpResponse };
expect(creation.listed.result?.tools).toContainEqual(
expect.objectContaining({ name: AUTOMATIONS_TOOL_NAME }),
);
expect(creation.reply.error, diagnostics(creation.reply)).toBeUndefined();
expect(creation.reply.result?.isError, diagnostics(creation.reply)).toBe(false);
const response: unknown = JSON.parse(
creation.reply.result?.content?.find((item) => item.type === "text")?.text ?? "null",
);
if (!isRecord(response) || typeof response.id !== "string") {
throw new Error(
diagnostics({ creation, reason: "automation creation returned no job id" }),
);
}
created = { id: response.id };
} else if (creator === "trusted") {
created = await gateway.client.request<{ id: string }>("cron.add", params);
} else {
created = await createAccountOwnedScheduledJob({
cfg: runtimeConfig,
gatewayPort,
agentId: "main",
accountId: creatorAccountId,
sessionKey: creatorSessionKey,
model: {
provider: runtime === "openclaw" ? embedded.providerId : "anthropic",
model: runtime === "openclaw" ? embedded.modelId : modelId,
},
job: params,
});
}
scheduledJob.id = created.id;
const storePath = resolveCronJobsStorePathFromConfig(getRuntimeConfig());
const job = expectDefined(
(await loadCronStore(storePath)).jobs.find((entry) => entry.id === created.id),
"persisted scheduled job",
);
if (nativeCreator) {
expect(job).toMatchObject({
owner: { sessionKey: creatorSessionKey, accountId: creatorAccountId },
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: creatorSessionKey,
ownerAccountId: creatorAccountId,
},
toolsAllowProvenance: {
version: 1,
channelRequester: {
version: 1,
channel: "discord",
accountId: creatorAccountId,
senderId: nativeRequesterId,
},
},
createdActor: { id: originalCreatorId },
});
expect(job.payload.toolsAllow).toEqual(["message"]);
expect(job.payload.toolsAllowIsDefault).toBeUndefined();
} else if (creator === "trusted") {
expect(job.scheduledToolPolicy).toEqual({ version: 1, mode: "trusted" });
} else {
expect(job.owner).toEqual({
agentId: "main",
sessionKey: creatorSessionKey,
accountId: creatorAccountId,
});
expect(job.scheduledToolPolicy).toEqual({
version: 1,
mode: "account",
ownerSessionKey: creatorSessionKey,
ownerAccountId: creatorAccountId,
});
expect(job.toolsAllowProvenance).toEqual({
version: 1,
source: "final-executable-surface",
callerOrigin: { kind: "local" },
});
expect(job.delivery).toMatchObject({
mode: "none",
channel: "discord",
accountId: "other",
});
}
expect(job).toMatchObject({ payload: { toolsAllow: ["message"] } });
expect(
await gateway.client.request("cron.run", { id: job.id, mode: "force" }),
).toMatchObject({ ok: true, enqueued: true });
if (heldPatch) {
try {
await withTestTimeout(
Promise.race([
heldPatch.entered.promise,
finished.promise.then((completion) => {
throw new Error(diagnostics({ completedBeforePatch: completion }));
}),
]),
45_000,
"Expected authenticated scheduled PATCH before disabling the job",
);
expect(edits).toEqual([{ topic: providerTopic }]);
await gateway.client.request("cron.update", {
id: job.id,
patch: { enabled: false },
});
const disabledJob = expectDefined(
(await loadCronStore(storePath)).jobs.find((entry) => entry.id === job.id),
"persisted disabled scheduled job",
);
expect(disabledJob).toMatchObject({
id: job.id,
enabled: false,
scheduledToolPolicy: { version: 1, mode: "trusted" },
payload: { toolsAllow: ["message"] },
});
if (disableBeforeResponse === 200) {
const requestSignal = expectDefined(
mcpHandlerSpy?.mock.calls.find(
([request]) =>
request.message.method === "tools/call" &&
request.hookContext?.workspaceDir === workspaceDir,
)?.[0].signal,
"original scheduled MCP request signal",
);
await withTestTimeout(
requestSignal.aborted
? Promise.resolve()
: new Promise<void>((resolve) => {
requestSignal.addEventListener("abort", () => resolve(), { once: true });
}),
10_000,
"Expected the disabled job to close its original MCP request",
);
}
} finally {
heldPatch.release.resolve();
}
}
const completion = await withTestTimeout(
finished.promise,
45_000,
"Expected scheduled agent completion",
);
if (!disableBeforeResponse) {
expect(completion, diagnostics(completion)).toMatchObject({ status: "ok" });
}
let text: string | undefined;
if (runtime === "claude-cli") {
const observation: {
listed: McpResponse;
reply: McpResponse;
followupReply?: McpResponse;
} = mcpHandlerSpy
? {
listed: await readObservedMcpResponse("tools/list"),
reply: await readObservedMcpResponse("tools/call"),
}
: JSON.parse(await readFile(path.join(root, "mcp-result.json"), "utf8"));
if (disableBeforeResponse) {
expect(await waitForActiveCronTaskRuns(10_000)).toEqual({ drained: true, active: 0 });
expect(getSuspensionVisibleCronTaskRunCount({ agentId: "main" })).toBe(0);
}
const messageTool = expectDefined(
observation.listed.result?.tools?.find((tool) => tool.name === "message"),
"scheduled CLI message tool",
);
assertAccountToolSchema?.(messageTool.inputSchema);
expect(observation.reply.error, diagnostics(observation.reply)).toBeUndefined();
expect(observation.reply.result?.isError, diagnostics(observation.reply)).toBe(
disableBeforeResponse === 429,
);
text = observation.reply.result?.content?.find((item) => item.type === "text")?.text;
if (nativeCreator) {
expect(requests).toContainEqual({
method: "GET",
path: `/api/v10/channels/${channelId}`,
authorizationMatches: true,
});
expect(requests).toContainEqual({
method: "GET",
path: `/api/v10/guilds/${guildId}/members/${nativeRequesterId}`,
authorizationMatches: true,
});
const denied = expectDefined(
observation.followupReply,
"second channel-edit MCP result",
);
expect(denied.error, diagnostics(denied)).toBeUndefined();
expect(denied.result?.isError, diagnostics(denied)).toBe(true);
expect(denied.result?.content?.map((item) => item.text).join("\n")).toContain(
"Sender does not have required permissions",
);
}
} else {
expect(embeddedModel.observation, diagnostics(completion)).toMatchObject({
requests: 2,
messageToolAdvertised: true,
});
text = embeddedModel.observation.toolOutput;
}
if (disableBeforeResponse === 429) {
expect(text).toMatch(/authority is no longer active|disabled by operator|abort|cancel/i);
} else {
assertToolResult(expectDefined(text, "scheduled provider tool result"));
}
if (action === "channel-edit") {
expect(requests.filter((request) => request.method === "PATCH")).toEqual([
{
method: "PATCH",
path: `/api/v10/channels/${channelId}`,
authorizationMatches: true,
},
]);
expect(edits).toEqual([{ topic: expectedTopic }]);
} else {
expect(requests).toContainEqual({
method: "GET",
path: `/api/v10/channels/${channelId}`,
authorizationMatches: true,
});
if (action === "read") {
expect(requests).toContainEqual({
method: "GET",
path: `/api/v10/channels/${channelId}/messages`,
authorizationMatches: true,
});
}
expect(edits).toEqual([]);
}
expect(providerErrors).toEqual([]);
},
// Release provider responses before Gateway shutdown waits for pending writes.
() => heldPatch?.release.resolve(),
() => runQaGatewayFixture(async () => {}, ...cleanup.toReversed()),
() => vi.restoreAllMocks(),
() => vi.unstubAllGlobals(),
() => vi.unstubAllEnvs(),
);
});
});