mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 08:01:40 +00:00
fix(cron): reject sessionTarget "main" for non-default agents at creation time (openclaw#30217) thanks @liaosvcaf
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: liaosvcaf <51533973+liaosvcaf@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
- 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.
|
- 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/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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -257,14 +257,105 @@ describe("applyJobPatch", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function createMockState(now: number): CronServiceState {
|
function createMockState(now: number, opts?: { defaultAgentId?: string }): CronServiceState {
|
||||||
return {
|
return {
|
||||||
deps: {
|
deps: {
|
||||||
nowMs: () => now,
|
nowMs: () => now,
|
||||||
|
defaultAgentId: opts?.defaultAgentId,
|
||||||
},
|
},
|
||||||
} as unknown as CronServiceState;
|
} 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", () => {
|
describe("cron stagger defaults", () => {
|
||||||
it("defaults top-of-hour cron jobs to 5m stagger", () => {
|
it("defaults top-of-hour cron jobs to 5m stagger", () => {
|
||||||
const now = Date.parse("2026-02-08T10:00:00.000Z");
|
const now = Date.parse("2026-02-08T10:00:00.000Z");
|
||||||
|
|||||||
@@ -509,39 +509,21 @@ describe("CronService", () => {
|
|||||||
await store.cleanup();
|
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 runHeartbeatOnce = vi.fn(async () => ({ status: "ran" as const, durationMs: 1 }));
|
||||||
|
|
||||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow } =
|
const { store, cron } = await createWakeModeNowMainHarness({
|
||||||
await createWakeModeNowMainHarness({
|
runHeartbeatOnce,
|
||||||
runHeartbeatOnce,
|
wakeNowHeartbeatBusyMaxWaitMs: 1,
|
||||||
// Perf: avoid advancing fake timers by 2+ minutes for the busy-heartbeat fallback.
|
wakeNowHeartbeatBusyRetryDelayMs: 2,
|
||||||
wakeNowHeartbeatBusyMaxWaitMs: 1,
|
|
||||||
wakeNowHeartbeatBusyRetryDelayMs: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessionKey = "agent:ops:discord:channel:alerts";
|
|
||||||
const job = await addWakeModeNowMainSystemEventJob(cron, {
|
|
||||||
name: "wakeMode now with agent",
|
|
||||||
agentId: "ops",
|
|
||||||
sessionKey,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await cron.run(job.id, "force");
|
await expect(
|
||||||
|
addWakeModeNowMainSystemEventJob(cron, {
|
||||||
expect(runHeartbeatOnce).toHaveBeenCalledTimes(1);
|
name: "wakeMode now with agent",
|
||||||
expect(runHeartbeatOnce).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
reason: `cron:${job.id}`,
|
|
||||||
agentId: "ops",
|
agentId: "ops",
|
||||||
sessionKey,
|
|
||||||
}),
|
}),
|
||||||
);
|
).rejects.toThrow('cron: sessionTarget "main" is only valid for the default agent');
|
||||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
|
||||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
|
||||||
"hello",
|
|
||||||
expect.objectContaining({ agentId: "ops", sessionKey }),
|
|
||||||
);
|
|
||||||
|
|
||||||
cron.stop();
|
cron.stop();
|
||||||
await store.cleanup();
|
await store.cleanup();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||||
import { parseAbsoluteTimeMs } from "../parse.js";
|
import { parseAbsoluteTimeMs } from "../parse.js";
|
||||||
import { computeNextRunAtMs } from "../schedule.js";
|
import { computeNextRunAtMs } from "../schedule.js";
|
||||||
import {
|
import {
|
||||||
@@ -91,6 +92,25 @@ export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "pay
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertMainSessionAgentId(
|
||||||
|
job: Pick<CronJob, "sessionTarget" | "agentId">,
|
||||||
|
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_TME_URL_REGEX = /^https?:\/\/t\.me\/|t\.me\//i;
|
||||||
const TELEGRAM_SLASH_TOPIC_REGEX = /^-?\d+\/\d+$/;
|
const TELEGRAM_SLASH_TOPIC_REGEX = /^-?\d+\/\d+$/;
|
||||||
|
|
||||||
@@ -426,12 +446,17 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
assertSupportedJobSpec(job);
|
assertSupportedJobSpec(job);
|
||||||
|
assertMainSessionAgentId(job, state.deps.defaultAgentId);
|
||||||
assertDeliverySupport(job);
|
assertDeliverySupport(job);
|
||||||
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
|
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
|
export function applyJobPatch(
|
||||||
|
job: CronJob,
|
||||||
|
patch: CronJobPatch,
|
||||||
|
opts?: { defaultAgentId?: string },
|
||||||
|
) {
|
||||||
if ("name" in patch) {
|
if ("name" in patch) {
|
||||||
job.name = normalizeRequiredName(patch.name);
|
job.name = normalizeRequiredName(patch.name);
|
||||||
}
|
}
|
||||||
@@ -501,6 +526,7 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
|
|||||||
job.sessionKey = normalizeOptionalSessionKey((patch as { sessionKey?: unknown }).sessionKey);
|
job.sessionKey = normalizeOptionalSessionKey((patch as { sessionKey?: unknown }).sessionKey);
|
||||||
}
|
}
|
||||||
assertSupportedJobSpec(job);
|
assertSupportedJobSpec(job);
|
||||||
|
assertMainSessionAgentId(job, opts?.defaultAgentId);
|
||||||
assertDeliverySupport(job);
|
assertDeliverySupport(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export async function update(state: CronServiceState, id: string, patch: CronJob
|
|||||||
await ensureLoaded(state, { skipRecompute: true });
|
await ensureLoaded(state, { skipRecompute: true });
|
||||||
const job = findJobOrThrow(state, id);
|
const job = findJobOrThrow(state, id);
|
||||||
const now = state.deps.nowMs();
|
const now = state.deps.nowMs();
|
||||||
applyJobPatch(job, patch);
|
applyJobPatch(job, patch, { defaultAgentId: state.deps.defaultAgentId });
|
||||||
if (job.schedule.kind === "every") {
|
if (job.schedule.kind === "every") {
|
||||||
const anchor = job.schedule.anchorMs;
|
const anchor = job.schedule.anchorMs;
|
||||||
if (typeof anchor !== "number" || !Number.isFinite(anchor)) {
|
if (typeof anchor !== "number" || !Number.isFinite(anchor)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user