diff --git a/CHANGELOG.md b/CHANGELOG.md index cc5b1b7902a..045c069d218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,7 @@ Docs: https://docs.openclaw.ai - 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. - Gateway/Control UI origins: support wildcard `"*"` in `gateway.controlUi.allowedOrigins` for trusted remote access setups. Landed from contributor PR #31088 by @frankekn. Thanks @frankekn. - 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. +- 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. - 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. - 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. - 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. diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index e133197fbad..18eef9240d1 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -257,14 +257,105 @@ describe("applyJobPatch", () => { }); }); -function createMockState(now: number): CronServiceState { +function createMockState(now: number, opts?: { defaultAgentId?: string }): CronServiceState { return { deps: { nowMs: () => now, + defaultAgentId: opts?.defaultAgentId, }, } as unknown as CronServiceState; } +describe("createJob rejects sessionTarget main for non-default agents", () => { + const now = Date.parse("2026-02-28T12:00:00.000Z"); + + const mainJobInput = (agentId?: string) => ({ + name: "my-main-job", + enabled: true, + schedule: { kind: "every" as const, everyMs: 60_000 }, + sessionTarget: "main" as const, + wakeMode: "now" as const, + payload: { kind: "systemEvent" as const, text: "tick" }, + ...(agentId !== undefined ? { agentId } : {}), + }); + + it("allows creating a main-session job for the default agent", () => { + const state = createMockState(now, { defaultAgentId: "main" }); + expect(() => createJob(state, mainJobInput())).not.toThrow(); + expect(() => createJob(state, mainJobInput("main"))).not.toThrow(); + }); + + it("allows creating a main-session job when defaultAgentId matches (case-insensitive)", () => { + const state = createMockState(now, { defaultAgentId: "Main" }); + expect(() => createJob(state, mainJobInput("MAIN"))).not.toThrow(); + }); + + it("rejects creating a main-session job for a non-default agentId", () => { + const state = createMockState(now, { defaultAgentId: "main" }); + expect(() => createJob(state, mainJobInput("custom-agent"))).toThrow( + 'cron: sessionTarget "main" is only valid for the default agent', + ); + }); + + it("rejects main-session job for non-default agent even without explicit defaultAgentId", () => { + const state = createMockState(now); + expect(() => createJob(state, mainJobInput("custom-agent"))).toThrow( + 'cron: sessionTarget "main" is only valid for the default agent', + ); + }); + + it("allows isolated session job for non-default agents", () => { + const state = createMockState(now, { defaultAgentId: "main" }); + expect(() => + createJob(state, { + name: "isolated-job", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "do it" }, + agentId: "custom-agent", + }), + ).not.toThrow(); + }); +}); + +describe("applyJobPatch rejects sessionTarget main for non-default agents", () => { + const now = Date.now(); + + const createMainJob = (agentId?: string): CronJob => ({ + id: "job-main-agent-check", + name: "main-agent-check", + enabled: true, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "tick" }, + state: {}, + agentId, + }); + + it("rejects patching agentId to non-default on a main-session job", () => { + const job = createMainJob(); + expect(() => + applyJobPatch(job, { agentId: "custom-agent" } as CronJobPatch, { + defaultAgentId: "main", + }), + ).toThrow('cron: sessionTarget "main" is only valid for the default agent'); + }); + + it("allows patching agentId to the default agent on a main-session job", () => { + const job = createMainJob(); + expect(() => + applyJobPatch(job, { agentId: "main" } as CronJobPatch, { + defaultAgentId: "main", + }), + ).not.toThrow(); + }); +}); + describe("cron stagger defaults", () => { it("defaults top-of-hour cron jobs to 5m stagger", () => { const now = Date.parse("2026-02-08T10:00:00.000Z"); diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index 5856a007b26..bcf5b919c34 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -509,39 +509,21 @@ describe("CronService", () => { await store.cleanup(); }); - it("passes agentId and preserves scoped session for wakeMode now main jobs", async () => { + it("rejects sessionTarget main for non-default agents at creation time", async () => { const runHeartbeatOnce = vi.fn(async () => ({ status: "ran" as const, durationMs: 1 })); - const { store, cron, enqueueSystemEvent, requestHeartbeatNow } = - await createWakeModeNowMainHarness({ - runHeartbeatOnce, - // Perf: avoid advancing fake timers by 2+ minutes for the busy-heartbeat fallback. - wakeNowHeartbeatBusyMaxWaitMs: 1, - wakeNowHeartbeatBusyRetryDelayMs: 2, - }); - - const sessionKey = "agent:ops:discord:channel:alerts"; - const job = await addWakeModeNowMainSystemEventJob(cron, { - name: "wakeMode now with agent", - agentId: "ops", - sessionKey, + const { store, cron } = await createWakeModeNowMainHarness({ + runHeartbeatOnce, + wakeNowHeartbeatBusyMaxWaitMs: 1, + wakeNowHeartbeatBusyRetryDelayMs: 2, }); - await cron.run(job.id, "force"); - - expect(runHeartbeatOnce).toHaveBeenCalledTimes(1); - expect(runHeartbeatOnce).toHaveBeenCalledWith( - expect.objectContaining({ - reason: `cron:${job.id}`, + await expect( + addWakeModeNowMainSystemEventJob(cron, { + name: "wakeMode now with agent", agentId: "ops", - sessionKey, }), - ); - expect(requestHeartbeatNow).not.toHaveBeenCalled(); - expect(enqueueSystemEvent).toHaveBeenCalledWith( - "hello", - expect.objectContaining({ agentId: "ops", sessionKey }), - ); + ).rejects.toThrow('cron: sessionTarget "main" is only valid for the default agent'); cron.stop(); await store.cleanup(); diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 5ccca6c43d3..7bb80ef37ac 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { parseAbsoluteTimeMs } from "../parse.js"; import { computeNextRunAtMs } from "../schedule.js"; import { @@ -91,6 +92,25 @@ export function assertSupportedJobSpec(job: Pick, + defaultAgentId: string | undefined, +) { + if (job.sessionTarget !== "main") { + return; + } + if (!job.agentId) { + return; + } + const normalized = normalizeAgentId(job.agentId); + const normalizedDefault = normalizeAgentId(defaultAgentId); + if (normalized !== normalizedDefault) { + throw new Error( + `cron: sessionTarget "main" is only valid for the default agent. Use sessionTarget "isolated" with payload.kind "agentTurn" for non-default agents (agentId: ${job.agentId})`, + ); + } +} + const TELEGRAM_TME_URL_REGEX = /^https?:\/\/t\.me\/|t\.me\//i; const TELEGRAM_SLASH_TOPIC_REGEX = /^-?\d+\/\d+$/; @@ -426,12 +446,17 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo }, }; assertSupportedJobSpec(job); + assertMainSessionAgentId(job, state.deps.defaultAgentId); assertDeliverySupport(job); job.state.nextRunAtMs = computeJobNextRunAtMs(job, now); return job; } -export function applyJobPatch(job: CronJob, patch: CronJobPatch) { +export function applyJobPatch( + job: CronJob, + patch: CronJobPatch, + opts?: { defaultAgentId?: string }, +) { if ("name" in patch) { job.name = normalizeRequiredName(patch.name); } @@ -501,6 +526,7 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) { job.sessionKey = normalizeOptionalSessionKey((patch as { sessionKey?: unknown }).sessionKey); } assertSupportedJobSpec(job); + assertMainSessionAgentId(job, opts?.defaultAgentId); assertDeliverySupport(job); } diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index af552acaabb..2b7ebf57f75 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -270,7 +270,7 @@ export async function update(state: CronServiceState, id: string, patch: CronJob await ensureLoaded(state, { skipRecompute: true }); const job = findJobOrThrow(state, id); const now = state.deps.nowMs(); - applyJobPatch(job, patch); + applyJobPatch(job, patch, { defaultAgentId: state.deps.defaultAgentId }); if (job.schedule.kind === "every") { const anchor = job.schedule.anchorMs; if (typeof anchor !== "number" || !Number.isFinite(anchor)) {