Skip to content

Commit 313a655

Browse files
fix(cron): reject sessionTarget "main" for non-default agents at creation time (#30217) thanks @liaosvcaf
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: liaosvcaf <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent e70fc5e commit 313a655

File tree

5 files changed

+130
-30
lines changed

5 files changed

+130
-30
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ Docs: https://docs.openclaw.ai
111111
- Signal/Loop protection: evaluate own-account detection before sync-message filtering (including UUID-only `accountUuid` configs) so `sentTranscript` sync events cannot bypass loop protection and self-reply loops. Landed from contributor PR #31093 by @kevinWangSheng. Thanks @kevinWangSheng.
112112
- Gateway/Control UI origins: support wildcard `"*"` in `gateway.controlUi.allowedOrigins` for trusted remote access setups. Landed from contributor PR #31088 by @frankekn. Thanks @frankekn.
113113
- Cron/Isolated CLI timeout ratio: avoid reusing persisted CLI session IDs on fresh isolated cron runs so the fresh watchdog profile is used and jobs do not abort at roughly one-third of configured `timeoutSeconds`. (#30140) Thanks @ningding97.
114+
- Cron/Session target guardrail: reject creating or patching `sessionTarget: "main"` cron jobs when `agentId` is not the default agent, preventing invalid cross-agent main-session bindings at write time. (#30217) Thanks @liaosvcaf.
114115
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.
115116
- Node host/service auth env: include `OPENCLAW_GATEWAY_TOKEN` in `openclaw node install` service environments (with `CLAWDBOT_GATEWAY_TOKEN` compatibility fallback) so installed node services keep remote gateway token auth across restart/reboot. Fixes #31041. Thanks @OneStepAt4time for reporting, @byungsker, @liuxiaopai-ai, and @vincentkoc.
116117
- Security/Subagents sandbox inheritance: block sandboxed sessions from spawning cross-agent subagents that would run unsandboxed, preventing runtime sandbox downgrade via `sessions_spawn agentId`. Thanks @tdjackey for reporting.

src/cron/service.jobs.test.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,14 +257,105 @@ describe("applyJobPatch", () => {
257257
});
258258
});
259259

260-
function createMockState(now: number): CronServiceState {
260+
function createMockState(now: number, opts?: { defaultAgentId?: string }): CronServiceState {
261261
return {
262262
deps: {
263263
nowMs: () => now,
264+
defaultAgentId: opts?.defaultAgentId,
264265
},
265266
} as unknown as CronServiceState;
266267
}
267268

269+
describe("createJob rejects sessionTarget main for non-default agents", () => {
270+
const now = Date.parse("2026-02-28T12:00:00.000Z");
271+
272+
const mainJobInput = (agentId?: string) => ({
273+
name: "my-main-job",
274+
enabled: true,
275+
schedule: { kind: "every" as const, everyMs: 60_000 },
276+
sessionTarget: "main" as const,
277+
wakeMode: "now" as const,
278+
payload: { kind: "systemEvent" as const, text: "tick" },
279+
...(agentId !== undefined ? { agentId } : {}),
280+
});
281+
282+
it("allows creating a main-session job for the default agent", () => {
283+
const state = createMockState(now, { defaultAgentId: "main" });
284+
expect(() => createJob(state, mainJobInput())).not.toThrow();
285+
expect(() => createJob(state, mainJobInput("main"))).not.toThrow();
286+
});
287+
288+
it("allows creating a main-session job when defaultAgentId matches (case-insensitive)", () => {
289+
const state = createMockState(now, { defaultAgentId: "Main" });
290+
expect(() => createJob(state, mainJobInput("MAIN"))).not.toThrow();
291+
});
292+
293+
it("rejects creating a main-session job for a non-default agentId", () => {
294+
const state = createMockState(now, { defaultAgentId: "main" });
295+
expect(() => createJob(state, mainJobInput("custom-agent"))).toThrow(
296+
'cron: sessionTarget "main" is only valid for the default agent',
297+
);
298+
});
299+
300+
it("rejects main-session job for non-default agent even without explicit defaultAgentId", () => {
301+
const state = createMockState(now);
302+
expect(() => createJob(state, mainJobInput("custom-agent"))).toThrow(
303+
'cron: sessionTarget "main" is only valid for the default agent',
304+
);
305+
});
306+
307+
it("allows isolated session job for non-default agents", () => {
308+
const state = createMockState(now, { defaultAgentId: "main" });
309+
expect(() =>
310+
createJob(state, {
311+
name: "isolated-job",
312+
enabled: true,
313+
schedule: { kind: "every", everyMs: 60_000 },
314+
sessionTarget: "isolated",
315+
wakeMode: "now",
316+
payload: { kind: "agentTurn", message: "do it" },
317+
agentId: "custom-agent",
318+
}),
319+
).not.toThrow();
320+
});
321+
});
322+
323+
describe("applyJobPatch rejects sessionTarget main for non-default agents", () => {
324+
const now = Date.now();
325+
326+
const createMainJob = (agentId?: string): CronJob => ({
327+
id: "job-main-agent-check",
328+
name: "main-agent-check",
329+
enabled: true,
330+
createdAtMs: now,
331+
updatedAtMs: now,
332+
schedule: { kind: "every", everyMs: 60_000 },
333+
sessionTarget: "main",
334+
wakeMode: "now",
335+
payload: { kind: "systemEvent", text: "tick" },
336+
state: {},
337+
agentId,
338+
});
339+
340+
it("rejects patching agentId to non-default on a main-session job", () => {
341+
const job = createMainJob();
342+
expect(() =>
343+
applyJobPatch(job, { agentId: "custom-agent" } as CronJobPatch, {
344+
defaultAgentId: "main",
345+
}),
346+
).toThrow('cron: sessionTarget "main" is only valid for the default agent');
347+
});
348+
349+
it("allows patching agentId to the default agent on a main-session job", () => {
350+
const job = createMainJob();
351+
expect(() =>
352+
applyJobPatch(job, { agentId: "main" } as CronJobPatch, {
353+
defaultAgentId: "main",
354+
}),
355+
).not.toThrow();
356+
});
357+
});
358+
268359
describe("cron stagger defaults", () => {
269360
it("defaults top-of-hour cron jobs to 5m stagger", () => {
270361
const now = Date.parse("2026-02-08T10:00:00.000Z");

src/cron/service.runs-one-shot-main-job-disables-it.test.ts

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -509,39 +509,21 @@ describe("CronService", () => {
509509
await store.cleanup();
510510
});
511511

512-
it("passes agentId and preserves scoped session for wakeMode now main jobs", async () => {
512+
it("rejects sessionTarget main for non-default agents at creation time", async () => {
513513
const runHeartbeatOnce = vi.fn(async () => ({ status: "ran" as const, durationMs: 1 }));
514514

515-
const { store, cron, enqueueSystemEvent, requestHeartbeatNow } =
516-
await createWakeModeNowMainHarness({
517-
runHeartbeatOnce,
518-
// Perf: avoid advancing fake timers by 2+ minutes for the busy-heartbeat fallback.
519-
wakeNowHeartbeatBusyMaxWaitMs: 1,
520-
wakeNowHeartbeatBusyRetryDelayMs: 2,
521-
});
522-
523-
const sessionKey = "agent:ops:discord:channel:alerts";
524-
const job = await addWakeModeNowMainSystemEventJob(cron, {
525-
name: "wakeMode now with agent",
526-
agentId: "ops",
527-
sessionKey,
515+
const { store, cron } = await createWakeModeNowMainHarness({
516+
runHeartbeatOnce,
517+
wakeNowHeartbeatBusyMaxWaitMs: 1,
518+
wakeNowHeartbeatBusyRetryDelayMs: 2,
528519
});
529520

530-
await cron.run(job.id, "force");
531-
532-
expect(runHeartbeatOnce).toHaveBeenCalledTimes(1);
533-
expect(runHeartbeatOnce).toHaveBeenCalledWith(
534-
expect.objectContaining({
535-
reason: `cron:${job.id}`,
521+
await expect(
522+
addWakeModeNowMainSystemEventJob(cron, {
523+
name: "wakeMode now with agent",
536524
agentId: "ops",
537-
sessionKey,
538525
}),
539-
);
540-
expect(requestHeartbeatNow).not.toHaveBeenCalled();
541-
expect(enqueueSystemEvent).toHaveBeenCalledWith(
542-
"hello",
543-
expect.objectContaining({ agentId: "ops", sessionKey }),
544-
);
526+
).rejects.toThrow('cron: sessionTarget "main" is only valid for the default agent');
545527

546528
cron.stop();
547529
await store.cleanup();

src/cron/service/jobs.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import crypto from "node:crypto";
2+
import { normalizeAgentId } from "../../routing/session-key.js";
23
import { parseAbsoluteTimeMs } from "../parse.js";
34
import { computeNextRunAtMs } from "../schedule.js";
45
import {
@@ -91,6 +92,25 @@ export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "pay
9192
}
9293
}
9394

95+
function assertMainSessionAgentId(
96+
job: Pick<CronJob, "sessionTarget" | "agentId">,
97+
defaultAgentId: string | undefined,
98+
) {
99+
if (job.sessionTarget !== "main") {
100+
return;
101+
}
102+
if (!job.agentId) {
103+
return;
104+
}
105+
const normalized = normalizeAgentId(job.agentId);
106+
const normalizedDefault = normalizeAgentId(defaultAgentId);
107+
if (normalized !== normalizedDefault) {
108+
throw new Error(
109+
`cron: sessionTarget "main" is only valid for the default agent. Use sessionTarget "isolated" with payload.kind "agentTurn" for non-default agents (agentId: ${job.agentId})`,
110+
);
111+
}
112+
}
113+
94114
const TELEGRAM_TME_URL_REGEX = /^https?:\/\/t\.me\/|t\.me\//i;
95115
const TELEGRAM_SLASH_TOPIC_REGEX = /^-?\d+\/\d+$/;
96116

@@ -426,12 +446,17 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
426446
},
427447
};
428448
assertSupportedJobSpec(job);
449+
assertMainSessionAgentId(job, state.deps.defaultAgentId);
429450
assertDeliverySupport(job);
430451
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
431452
return job;
432453
}
433454

434-
export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
455+
export function applyJobPatch(
456+
job: CronJob,
457+
patch: CronJobPatch,
458+
opts?: { defaultAgentId?: string },
459+
) {
435460
if ("name" in patch) {
436461
job.name = normalizeRequiredName(patch.name);
437462
}
@@ -501,6 +526,7 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
501526
job.sessionKey = normalizeOptionalSessionKey((patch as { sessionKey?: unknown }).sessionKey);
502527
}
503528
assertSupportedJobSpec(job);
529+
assertMainSessionAgentId(job, opts?.defaultAgentId);
504530
assertDeliverySupport(job);
505531
}
506532

src/cron/service/ops.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ export async function update(state: CronServiceState, id: string, patch: CronJob
270270
await ensureLoaded(state, { skipRecompute: true });
271271
const job = findJobOrThrow(state, id);
272272
const now = state.deps.nowMs();
273-
applyJobPatch(job, patch);
273+
applyJobPatch(job, patch, { defaultAgentId: state.deps.defaultAgentId });
274274
if (job.schedule.kind === "every") {
275275
const anchor = job.schedule.anchorMs;
276276
if (typeof anchor !== "number" || !Number.isFinite(anchor)) {

0 commit comments

Comments
 (0)