Feat(cron): add sandbox policy controls for visibility, escape, and delivery

This commit is contained in:
Christof Salis 2026-01-26 23:32:14 +01:00
parent 640c8d1554
commit fed475f9ac
17 changed files with 1338 additions and 20 deletions

View File

@ -124,4 +124,35 @@ describe("sandbox config merges", () => {
});
expect(pruneShared).toEqual({ idleHours: 24, maxAgeDays: 7 });
});
it("merges sandbox cron policy (agent overrides global)", async () => {
const { resolveSandboxCronConfig } = await import("./sandbox.js");
const resolved = resolveSandboxCronConfig({
globalCron: {
visibility: "all",
escape: "elevated",
allowMainSessionJobs: true,
delivery: "explicit",
},
agentCron: {
visibility: "agent",
escape: "off",
},
});
expect(resolved).toEqual({
visibility: "agent",
escape: "off",
allowMainSessionJobs: true,
delivery: "explicit",
});
});
it("uses default cron policy when none provided", async () => {
const { resolveSandboxCronConfig } = await import("./sandbox.js");
const { DEFAULT_SANDBOX_CRON_POLICY } = await import("./sandbox/constants.js");
expect(resolveSandboxCronConfig({})).toEqual(DEFAULT_SANDBOX_CRON_POLICY);
});
});

View File

@ -1,6 +1,7 @@
export {
resolveSandboxBrowserConfig,
resolveSandboxConfigForAgent,
resolveSandboxCronConfig,
resolveSandboxDockerConfig,
resolveSandboxPruneConfig,
resolveSandboxScope,
@ -32,6 +33,7 @@ export type {
SandboxBrowserConfig,
SandboxBrowserContext,
SandboxConfig,
SandboxCronPolicy,
SandboxContext,
SandboxDockerConfig,
SandboxPruneConfig,

View File

@ -11,6 +11,7 @@ import {
DEFAULT_SANDBOX_IDLE_HOURS,
DEFAULT_SANDBOX_IMAGE,
DEFAULT_SANDBOX_MAX_AGE_DAYS,
DEFAULT_SANDBOX_CRON_POLICY,
DEFAULT_SANDBOX_WORKDIR,
DEFAULT_SANDBOX_WORKSPACE_ROOT,
} from "./constants.js";
@ -21,6 +22,7 @@ import type {
SandboxDockerConfig,
SandboxPruneConfig,
SandboxScope,
SandboxCronPolicy,
} from "./types.js";
export function resolveSandboxScope(params: {
@ -121,6 +123,24 @@ export function resolveSandboxPruneConfig(params: {
};
}
export function resolveSandboxCronConfig(params: {
globalCron?: Partial<SandboxCronPolicy>;
agentCron?: Partial<SandboxCronPolicy>;
}): SandboxCronPolicy {
const agentCron = params.agentCron;
const globalCron = params.globalCron;
return {
visibility:
agentCron?.visibility ?? globalCron?.visibility ?? DEFAULT_SANDBOX_CRON_POLICY.visibility,
escape: agentCron?.escape ?? globalCron?.escape ?? DEFAULT_SANDBOX_CRON_POLICY.escape,
allowMainSessionJobs:
agentCron?.allowMainSessionJobs ??
globalCron?.allowMainSessionJobs ??
DEFAULT_SANDBOX_CRON_POLICY.allowMainSessionJobs,
delivery: agentCron?.delivery ?? globalCron?.delivery ?? DEFAULT_SANDBOX_CRON_POLICY.delivery,
};
}
export function resolveSandboxConfigForAgent(cfg?: MoltbotConfig, agentId?: string): SandboxConfig {
const agent = cfg?.agents?.defaults?.sandbox;
@ -163,5 +183,9 @@ export function resolveSandboxConfigForAgent(cfg?: MoltbotConfig, agentId?: stri
globalPrune: agent?.prune,
agentPrune: agentSandbox?.prune,
}),
cron: resolveSandboxCronConfig({
globalCron: agent?.cron,
agentCron: agentSandbox?.cron,
}),
};
}

View File

@ -37,9 +37,15 @@ export const DEFAULT_TOOL_DENY = [
...CHANNEL_IDS,
] as const;
export const DEFAULT_SANDBOX_CRON_POLICY = {
visibility: "agent",
escape: "off",
allowMainSessionJobs: false,
delivery: "last-only",
} as const;
export const DEFAULT_SANDBOX_BROWSER_IMAGE = "moltbot-sandbox-browser:bookworm-slim";
export const DEFAULT_SANDBOX_COMMON_IMAGE = "moltbot-sandbox-common:bookworm-slim";
export const DEFAULT_SANDBOX_BROWSER_PREFIX = "moltbot-sbx-browser-";
export const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222;
export const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900;

View File

@ -48,6 +48,13 @@ export type SandboxPruneConfig = {
export type SandboxScope = "session" | "agent" | "shared";
export type SandboxCronPolicy = {
visibility: "agent" | "all";
escape: "off" | "elevated" | "elevated-full";
allowMainSessionJobs: boolean;
delivery: "off" | "last-only" | "explicit";
};
export type SandboxConfig = {
mode: "off" | "non-main" | "all";
scope: SandboxScope;
@ -57,6 +64,7 @@ export type SandboxConfig = {
browser: SandboxBrowserConfig;
tools: SandboxToolPolicy;
prune: SandboxPruneConfig;
cron: SandboxCronPolicy;
};
export type SandboxBrowserContext = {

View File

@ -1,12 +1,34 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
const resolveSandboxRuntimeStatusMock = vi.fn(() => ({ sandboxed: false, agentId: "agent-123" }));
const resolveSandboxConfigForAgentMock = vi.fn(() => ({
cron: {
visibility: "agent",
escape: "off",
allowMainSessionJobs: false,
delivery: "last-only",
},
}));
const loadSessionStoreMock = vi.fn(() => ({}));
const resolveStorePathMock = vi.fn(() => "/tmp/clawdbot-sessions.json");
vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../agent-scope.js", () => ({
resolveSessionAgentId: () => "agent-123",
vi.mock("../sandbox/runtime-status.js", () => ({
resolveSandboxRuntimeStatus: (opts: unknown) => resolveSandboxRuntimeStatusMock(opts),
}));
vi.mock("../sandbox/config.js", () => ({
resolveSandboxConfigForAgent: (cfg: unknown, agentId?: string) =>
resolveSandboxConfigForAgentMock(cfg, agentId),
}));
vi.mock("../../config/sessions.js", () => ({
loadSessionStore: (path?: string) => loadSessionStoreMock(path),
resolveStorePath: (cfg: unknown, opts?: unknown) => resolveStorePathMock(cfg, opts),
}));
import { createCronTool } from "./cron-tool.js";
@ -15,6 +37,21 @@ describe("cron tool", () => {
beforeEach(() => {
callGatewayMock.mockReset();
callGatewayMock.mockResolvedValue({ ok: true });
resolveSandboxRuntimeStatusMock.mockReset();
resolveSandboxRuntimeStatusMock.mockReturnValue({ sandboxed: false, agentId: "agent-123" });
resolveSandboxConfigForAgentMock.mockReset();
resolveSandboxConfigForAgentMock.mockReturnValue({
cron: {
visibility: "agent",
escape: "off",
allowMainSessionJobs: false,
delivery: "last-only",
},
});
loadSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({});
resolveStorePathMock.mockReset();
resolveStorePathMock.mockReturnValue("/tmp/clawdbot-sessions.json");
});
it.each([
@ -231,4 +268,535 @@ describe("cron tool", () => {
expect(call.method).toBe("cron.add");
expect(call.params?.agentId).toBeNull();
});
it("scopes list for sandboxed agents when visibility=agent", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await tool.execute("call7", { action: "list" });
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: unknown;
};
expect(call.method).toBe("cron.list");
expect(call.params).toEqual({ includeDisabled: false, actorAgentId: "agent-123" });
});
it("skips actor scoping when sandbox escape is elevated", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
resolveSandboxConfigForAgentMock.mockReturnValue({
cron: {
visibility: "agent",
escape: "elevated",
allowMainSessionJobs: false,
delivery: "last-only",
},
});
loadSessionStoreMock.mockReturnValue({
"sess-1": { elevatedLevel: "on" },
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await tool.execute("call8", { action: "list" });
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: unknown;
};
expect(call.params).toEqual({ includeDisabled: false });
});
it("keeps actor scoping when escape is elevated-full but elevated is on", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
resolveSandboxConfigForAgentMock.mockReturnValue({
cron: {
visibility: "agent",
escape: "elevated-full",
allowMainSessionJobs: false,
delivery: "last-only",
},
});
loadSessionStoreMock.mockReturnValue({
"sess-1": { elevatedLevel: "on" },
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await tool.execute("call8b", { action: "list" });
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: unknown;
};
expect(call.params).toEqual({ includeDisabled: false, actorAgentId: "agent-123" });
});
it("skips actor scoping when escape is elevated-full and elevated is full", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
resolveSandboxConfigForAgentMock.mockReturnValue({
cron: {
visibility: "agent",
escape: "elevated-full",
allowMainSessionJobs: false,
delivery: "last-only",
},
});
loadSessionStoreMock.mockReturnValue({
"sess-1": { elevatedLevel: "full" },
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await tool.execute("call8c", { action: "list" });
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: unknown;
};
expect(call.params).toEqual({ includeDisabled: false });
});
it("blocks main session cron jobs for sandboxed agents", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await expect(
tool.execute("call9", {
action: "add",
job: {
name: "main",
schedule: { atMs: 123 },
sessionTarget: "main",
payload: { kind: "systemEvent", text: "hello" },
},
}),
).rejects.toThrow("sandboxed cron jobs cannot target main sessions");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("allows main session cron jobs when allowMainSessionJobs is true", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
resolveSandboxConfigForAgentMock.mockReturnValue({
cron: {
visibility: "agent",
escape: "off",
allowMainSessionJobs: true,
delivery: "last-only",
},
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await tool.execute("call-main-allowed", {
action: "add",
job: {
name: "main",
schedule: { atMs: 123 },
sessionTarget: "main",
payload: { kind: "systemEvent", text: "hello" },
},
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
};
expect(call.method).toBe("cron.add");
});
it("allows wake when allowMainSessionJobs is true", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
resolveSandboxConfigForAgentMock.mockReturnValue({
cron: {
visibility: "agent",
escape: "off",
allowMainSessionJobs: true,
delivery: "last-only",
},
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await tool.execute("call-wake-allowed", {
action: "wake",
text: "wake up",
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
};
expect(call.method).toBe("wake");
});
it("blocks updates to main session cron jobs when patch omits sessionTarget", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
callGatewayMock.mockResolvedValueOnce({
jobs: [{ id: "job-1", sessionTarget: "main", agentId: "agent-123" }],
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await expect(
tool.execute("call-update-main", {
action: "update",
jobId: "job-1",
patch: { name: "updated" },
}),
).rejects.toThrow("sandboxed cron jobs cannot target main sessions");
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: unknown;
};
expect(call.method).toBe("cron.list");
expect(call.params).toEqual({ includeDisabled: true, actorAgentId: "agent-123" });
});
it.each(["remove", "run", "runs"])(
"blocks %s for main session cron jobs when disallowed",
async (action) => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
callGatewayMock.mockResolvedValueOnce({
jobs: [{ id: "job-1", sessionTarget: "main", agentId: "agent-123" }],
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await expect(
tool.execute("call-main", {
action,
jobId: "job-1",
}),
).rejects.toThrow("sandboxed cron jobs cannot target main sessions");
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: unknown;
};
expect(call.method).toBe("cron.list");
expect(call.params).toEqual({ includeDisabled: true, actorAgentId: "agent-123" });
},
);
it("rejects cross-agent cron jobs for sandboxed agents", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await expect(
tool.execute("call10", {
action: "add",
job: {
name: "other",
schedule: { atMs: 123 },
sessionTarget: "isolated",
agentId: "other-agent",
payload: { kind: "agentTurn", message: "hi" },
},
}),
).rejects.toThrow("cron agentId must match the sandboxed agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("allows cross-agent cron jobs when visibility=all", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
resolveSandboxConfigForAgentMock.mockReturnValue({
cron: {
visibility: "all",
escape: "off",
allowMainSessionJobs: false,
delivery: "last-only",
},
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await tool.execute("call10b", {
action: "add",
job: {
name: "other",
schedule: { atMs: 123 },
sessionTarget: "isolated",
agentId: "other-agent",
payload: { kind: "agentTurn", message: "hi" },
},
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: { agentId?: unknown };
};
expect(call.method).toBe("cron.add");
expect(call.params?.agentId).toBe("other-agent");
});
it("rejects agentId mismatch on update for sandboxed agents", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await expect(
tool.execute("call-update-agent-mismatch", {
action: "update",
jobId: "job-1",
patch: { agentId: "other-agent" },
}),
).rejects.toThrow("cron agentId must match the sandboxed agent");
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: unknown;
};
expect(call.method).toBe("cron.list");
expect(call.params).toEqual({ includeDisabled: true, actorAgentId: "agent-123" });
});
it("enforces delivery restrictions for sandboxed agents", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
resolveSandboxConfigForAgentMock.mockReturnValue({
cron: {
visibility: "agent",
escape: "off",
allowMainSessionJobs: true,
delivery: "off",
},
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await expect(
tool.execute("call11", {
action: "add",
job: {
name: "deliver",
schedule: { atMs: 123 },
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "hi", deliver: true },
},
}),
).rejects.toThrow("cron delivery is disabled for sandboxed sessions");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("allows explicit delivery for sandboxed agents when policy is explicit", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
resolveSandboxConfigForAgentMock.mockReturnValue({
cron: {
visibility: "agent",
escape: "off",
allowMainSessionJobs: true,
delivery: "explicit",
},
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await tool.execute("call-explicit", {
action: "add",
job: {
name: "deliver",
schedule: { atMs: 123 },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message: "hi",
deliver: true,
channel: "sms",
to: "555-1212",
},
},
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
};
expect(call.method).toBe("cron.add");
});
it("allows last-only delivery when channel is last", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
resolveSandboxConfigForAgentMock.mockReturnValue({
cron: {
visibility: "agent",
escape: "off",
allowMainSessionJobs: true,
delivery: "last-only",
},
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await tool.execute("call-last-only", {
action: "add",
job: {
name: "deliver",
schedule: { atMs: 123 },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message: "hi",
deliver: true,
channel: "last",
},
},
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
};
expect(call.method).toBe("cron.add");
});
it("rejects run when delivery is disabled for sandboxed agents", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
resolveSandboxConfigForAgentMock.mockReturnValue({
cron: {
visibility: "agent",
escape: "off",
allowMainSessionJobs: true,
delivery: "off",
},
});
callGatewayMock.mockResolvedValueOnce({
jobs: [
{
id: "job-1",
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "hi", deliver: true },
},
],
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await expect(
tool.execute("call-run-delivery-off", {
action: "run",
jobId: "job-1",
}),
).rejects.toThrow("cron delivery is disabled for sandboxed sessions");
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: unknown;
};
expect(call.method).toBe("cron.list");
expect(call.params).toEqual({ includeDisabled: true, actorAgentId: "agent-123" });
});
it("rejects run when delivery is restricted to last route", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
resolveSandboxConfigForAgentMock.mockReturnValue({
cron: {
visibility: "agent",
escape: "off",
allowMainSessionJobs: true,
delivery: "last-only",
},
});
callGatewayMock.mockResolvedValueOnce({
jobs: [
{
id: "job-2",
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "hi", channel: "sms" },
},
],
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await expect(
tool.execute("call-run-last-only", {
action: "run",
jobId: "job-2",
}),
).rejects.toThrow("cron delivery channel is restricted to last route");
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: unknown;
};
expect(call.method).toBe("cron.list");
expect(call.params).toEqual({ includeDisabled: true, actorAgentId: "agent-123" });
});
it("adds actorAgentId on update for sandboxed agents", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await tool.execute("call12", {
action: "update",
jobId: "job-1",
patch: { name: "updated" },
});
const call = callGatewayMock.mock.calls.at(-1)?.[0] as {
params?: unknown;
};
expect(call.params).toEqual({
id: "job-1",
patch: { name: "updated" },
actorAgentId: "agent-123",
});
});
it("blocks wake for sandboxed agents when main session jobs are disallowed", async () => {
resolveSandboxRuntimeStatusMock.mockReturnValue({
sandboxed: true,
agentId: "agent-123",
});
const tool = createCronTool({ agentSessionKey: "sess-1" });
await expect(
tool.execute("call13", {
action: "wake",
text: "wake up",
}),
).rejects.toThrow("sandboxed cron cannot send wake events to main sessions");
expect(callGatewayMock).not.toHaveBeenCalled();
});
});

View File

@ -1,9 +1,13 @@
import { Type } from "@sinclair/typebox";
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
import { loadConfig } from "../../config/config.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { normalizeElevatedLevel } from "../../auto-reply/thinking.js";
import { resolveSandboxConfigForAgent } from "../sandbox/config.js";
import { resolveSandboxRuntimeStatus } from "../sandbox/runtime-status.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { truncateUtf16Safe } from "../../utils.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
@ -44,6 +48,19 @@ type CronToolOptions = {
agentSessionKey?: string;
};
type CronSandboxAccess = {
sandboxed: boolean;
restricted: boolean;
agentId?: string;
sessionKey?: string;
policy: {
visibility: "agent" | "all";
escape: "off" | "elevated" | "elevated-full";
allowMainSessionJobs: boolean;
delivery: "off" | "last-only" | "explicit";
};
};
type ChatMessage = {
role?: unknown;
content?: unknown;
@ -87,6 +104,143 @@ function extractMessageText(message: ChatMessage): { role: string; text: string
return joined ? { role, text: joined } : null;
}
function resolveCronSandboxAccess(params: {
cfg: ReturnType<typeof loadConfig>;
sessionKey?: string;
}): CronSandboxAccess {
const rawSessionKey = params.sessionKey?.trim();
if (!rawSessionKey) {
return {
sandboxed: false,
restricted: false,
agentId: undefined,
sessionKey: undefined,
policy: {
visibility: "all",
escape: "off",
allowMainSessionJobs: true,
delivery: "explicit",
},
};
}
const runtime = resolveSandboxRuntimeStatus({ cfg: params.cfg, sessionKey: rawSessionKey });
const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, runtime.agentId);
const policy = sandboxCfg.cron;
const normalizedAgentId = normalizeAgentId(runtime.agentId);
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: normalizedAgentId,
});
const store = loadSessionStore(storePath);
const entry = store[rawSessionKey];
const elevatedLevel = normalizeElevatedLevel(entry?.elevatedLevel) ?? "off";
const elevatedOn = elevatedLevel !== "off";
const elevatedFull = elevatedLevel === "full";
const escapeAllowed =
policy.escape === "elevated"
? elevatedOn
: policy.escape === "elevated-full"
? elevatedFull
: false;
return {
sandboxed: runtime.sandboxed,
restricted: runtime.sandboxed && !escapeAllowed,
agentId: normalizedAgentId,
sessionKey: rawSessionKey,
policy,
};
}
function jobAgentId(job: Record<string, unknown>) {
const raw = (job as { agentId?: unknown }).agentId;
if (typeof raw !== "string") return undefined;
return normalizeAgentId(raw);
}
function jobSessionTarget(job: Record<string, unknown>) {
const raw = (job as { sessionTarget?: unknown }).sessionTarget;
return typeof raw === "string" ? raw : undefined;
}
function jobPayload(job: Record<string, unknown>) {
const raw = (job as { payload?: unknown }).payload;
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : null;
}
async function resolveJobRecord(params: {
jobId: string;
gatewayOpts: GatewayCallOptions;
scopedAgentId?: string;
}) {
const res = await callGatewayTool("cron.list", params.gatewayOpts, {
includeDisabled: true,
...(params.scopedAgentId ? { actorAgentId: params.scopedAgentId } : {}),
});
const jobs = (res as { jobs?: unknown }).jobs;
if (!Array.isArray(jobs)) return undefined;
const match = jobs.find(
(job) => job && typeof job === "object" && (job as { id?: unknown }).id === params.jobId,
);
if (!match || typeof match !== "object") return undefined;
return match as Record<string, unknown>;
}
async function assertMainSessionJobAllowed(params: {
access: CronSandboxAccess;
jobId: string;
gatewayOpts: GatewayCallOptions;
scopedAgentId?: string;
jobRecord?: Record<string, unknown>;
}) {
if (!params.access.restricted || params.access.policy.allowMainSessionJobs) return;
const sessionTarget =
jobSessionTarget(params.jobRecord ?? {}) ??
jobSessionTarget(
(await resolveJobRecord({
jobId: params.jobId,
gatewayOpts: params.gatewayOpts,
scopedAgentId: params.scopedAgentId,
})) ?? {},
);
if (sessionTarget === "main") {
throw new Error("sandboxed cron jobs cannot target main sessions");
}
}
function assertDeliveryPolicy(
policy: CronSandboxAccess["policy"],
payload?: Record<string, unknown> | null,
) {
if (!payload) return;
if (payload.kind !== "agentTurn") return;
const deliver = typeof payload.deliver === "boolean" ? payload.deliver : undefined;
const channel = typeof payload.channel === "string" ? payload.channel : undefined;
const to = typeof payload.to === "string" ? payload.to : undefined;
if (policy.delivery === "explicit") return;
if (policy.delivery === "off") {
if (deliver || channel || to) {
throw new Error("cron delivery is disabled for sandboxed sessions");
}
return;
}
// last-only
if (to) {
throw new Error("cron delivery target is restricted to last route");
}
if (channel && channel !== "last") {
throw new Error("cron delivery channel is restricted to last route");
}
if (deliver && channel && channel !== "last") {
throw new Error("cron delivery channel is restricted to last route");
}
}
async function buildReminderContextLines(params: {
agentSessionKey?: string;
gatewayOpts: GatewayCallOptions;
@ -181,6 +335,13 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const cfg = loadConfig();
const access = resolveCronSandboxAccess({
cfg,
sessionKey: opts?.agentSessionKey,
});
const scopedAgentId =
access.restricted && access.policy.visibility === "agent" ? access.agentId : undefined;
const gatewayOpts: GatewayCallOptions = {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
@ -194,6 +355,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
return jsonResult(
await callGatewayTool("cron.list", gatewayOpts, {
includeDisabled: Boolean(params.includeDisabled),
...(scopedAgentId ? { actorAgentId: scopedAgentId } : {}),
}),
);
case "add": {
@ -201,13 +363,31 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
throw new Error("job required");
}
const job = normalizeCronJobCreate(params.job) ?? params.job;
if (job && typeof job === "object" && !("agentId" in job)) {
const cfg = loadConfig();
const agentId = opts?.agentSessionKey
? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg })
: undefined;
if (agentId) {
(job as { agentId?: string }).agentId = agentId;
if (job && typeof job === "object") {
const jobRecord = job as Record<string, unknown>;
const sessionTarget = jobSessionTarget(jobRecord);
const payload = jobPayload(jobRecord);
if (access.restricted && !access.policy.allowMainSessionJobs) {
if (sessionTarget === "main") {
throw new Error("sandboxed cron jobs cannot target main sessions");
}
}
if (access.restricted) {
assertDeliveryPolicy(access.policy, payload);
}
const enforceAgentVisibility =
access.restricted && access.policy.visibility === "agent";
if (!("agentId" in jobRecord)) {
const agentId = enforceAgentVisibility ? access.agentId : undefined;
if (agentId) {
jobRecord.agentId = agentId;
}
} else if (enforceAgentVisibility && access.agentId) {
const requested = jobAgentId(jobRecord);
if (requested && requested !== access.agentId) {
throw new Error("cron agentId must match the sandboxed agent");
}
jobRecord.agentId = access.agentId;
}
}
const contextMessages =
@ -233,7 +413,12 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
}
}
}
return jsonResult(await callGatewayTool("cron.add", gatewayOpts, job));
return jsonResult(
await callGatewayTool("cron.add", gatewayOpts, {
...(job as Record<string, unknown>),
...(scopedAgentId ? { actorAgentId: scopedAgentId } : {}),
}),
);
}
case "update": {
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
@ -244,10 +429,37 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
throw new Error("patch required");
}
const patch = normalizeCronJobPatch(params.patch) ?? params.patch;
if (access.restricted && patch && typeof patch === "object") {
const patchRecord = patch as Record<string, unknown>;
if (!access.policy.allowMainSessionJobs) {
const sessionTarget = jobSessionTarget(patchRecord);
if (sessionTarget === "main") {
throw new Error("sandboxed cron jobs cannot target main sessions");
}
if (!sessionTarget) {
await assertMainSessionJobAllowed({
access,
jobId: id,
gatewayOpts,
scopedAgentId,
});
}
}
assertDeliveryPolicy(access.policy, jobPayload(patchRecord));
const enforceAgentVisibility = access.policy.visibility === "agent" && access.agentId;
if (enforceAgentVisibility && "agentId" in patchRecord) {
const requested = jobAgentId(patchRecord);
if (requested && requested !== access.agentId) {
throw new Error("cron agentId must match the sandboxed agent");
}
patchRecord.agentId = access.agentId;
}
}
return jsonResult(
await callGatewayTool("cron.update", gatewayOpts, {
id,
patch,
...(scopedAgentId ? { actorAgentId: scopedAgentId } : {}),
}),
);
}
@ -256,21 +468,71 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
if (!id) {
throw new Error("jobId required (id accepted for backward compatibility)");
}
return jsonResult(await callGatewayTool("cron.remove", gatewayOpts, { id }));
await assertMainSessionJobAllowed({
access,
jobId: id,
gatewayOpts,
scopedAgentId,
});
return jsonResult(
await callGatewayTool("cron.remove", gatewayOpts, {
id,
...(scopedAgentId ? { actorAgentId: scopedAgentId } : {}),
}),
);
}
case "run": {
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
if (!id) {
throw new Error("jobId required (id accepted for backward compatibility)");
}
return jsonResult(await callGatewayTool("cron.run", gatewayOpts, { id }));
if (access.restricted) {
const needsMainSessionCheck = !access.policy.allowMainSessionJobs;
const needsDeliveryCheck = access.policy.delivery !== "explicit";
if (needsMainSessionCheck || needsDeliveryCheck) {
const jobRecord = await resolveJobRecord({
jobId: id,
gatewayOpts,
scopedAgentId,
});
if (needsMainSessionCheck) {
await assertMainSessionJobAllowed({
access,
jobId: id,
gatewayOpts,
scopedAgentId,
jobRecord,
});
}
if (needsDeliveryCheck) {
assertDeliveryPolicy(access.policy, jobPayload(jobRecord ?? {}));
}
}
}
return jsonResult(
await callGatewayTool("cron.run", gatewayOpts, {
id,
...(scopedAgentId ? { actorAgentId: scopedAgentId } : {}),
}),
);
}
case "runs": {
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
if (!id) {
throw new Error("jobId required (id accepted for backward compatibility)");
}
return jsonResult(await callGatewayTool("cron.runs", gatewayOpts, { id }));
await assertMainSessionJobAllowed({
access,
jobId: id,
gatewayOpts,
scopedAgentId,
});
return jsonResult(
await callGatewayTool("cron.runs", gatewayOpts, {
id,
...(scopedAgentId ? { actorAgentId: scopedAgentId } : {}),
}),
);
}
case "wake": {
const text = readStringParam(params, "text", { required: true });
@ -278,6 +540,9 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
params.mode === "now" || params.mode === "next-heartbeat"
? params.mode
: "next-heartbeat";
if (access.restricted && !access.policy.allowMainSessionJobs) {
throw new Error("sandboxed cron cannot send wake events to main sessions");
}
return jsonResult(
await callGatewayTool("wake", gatewayOpts, { mode, text }, { expectFinal: false }),
);

View File

@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";
describe("sandbox cron config", () => {
it("accepts cron policy entries for sandbox config", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
cron: {
visibility: "agent",
escape: "elevated",
allowMainSessionJobs: false,
delivery: "last-only",
},
},
},
list: [
{
id: "ops",
sandbox: {
cron: {
visibility: "all",
escape: "off",
allowMainSessionJobs: true,
delivery: "explicit",
},
},
},
],
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.agents?.defaults?.sandbox?.cron?.visibility).toBe("agent");
expect(res.config.agents?.list?.[0]?.sandbox?.cron?.visibility).toBe("all");
}
});
it("rejects invalid cron policy values", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
cron: {
visibility: "everyone",
escape: "nope",
delivery: "sometimes",
},
},
},
},
});
expect(res.ok).toBe(false);
});
it("accepts elevated-full escape and allowMainSessionJobs", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
cron: {
visibility: "all",
escape: "elevated-full",
allowMainSessionJobs: true,
delivery: "explicit",
},
},
},
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.agents?.defaults?.sandbox?.cron?.escape).toBe("elevated-full");
expect(res.config.agents?.defaults?.sandbox?.cron?.allowMainSessionJobs).toBe(true);
}
});
});

View File

@ -7,6 +7,7 @@ import type {
import type { ChannelId } from "../channels/plugins/types.js";
import type {
SandboxBrowserSettings,
SandboxCronSettings,
SandboxDockerSettings,
SandboxPruneSettings,
} from "./types.sandbox.js";
@ -234,6 +235,8 @@ export type AgentDefaultsConfig = {
browser?: SandboxBrowserSettings;
/** Auto-prune sandbox containers. */
prune?: SandboxPruneSettings;
/** Cron access policy for sandboxed sessions. */
cron?: SandboxCronSettings;
};
};

View File

@ -3,6 +3,7 @@ import type { HumanDelayConfig, IdentityConfig } from "./types.base.js";
import type { GroupChatConfig } from "./types.messages.js";
import type {
SandboxBrowserSettings,
SandboxCronSettings,
SandboxDockerSettings,
SandboxPruneSettings,
} from "./types.sandbox.js";
@ -58,6 +59,8 @@ export type AgentConfig = {
browser?: SandboxBrowserSettings;
/** Auto-prune overrides for this agent. */
prune?: SandboxPruneSettings;
/** Cron access policy for sandboxed sessions. */
cron?: SandboxCronSettings;
};
tools?: AgentToolsConfig;
};

View File

@ -73,3 +73,35 @@ export type SandboxPruneSettings = {
/** Prune if older than N days (0 disables). */
maxAgeDays?: number;
};
export type SandboxCronVisibility = "agent" | "all";
export type SandboxCronEscape = "off" | "elevated" | "elevated-full";
export type SandboxCronDelivery = "off" | "last-only" | "explicit";
export type SandboxCronSettings = {
/**
* Cron visibility for sandboxed sessions.
* - "agent": only see/manage jobs for this agent
* - "all": full cron visibility (admin)
*/
visibility?: SandboxCronVisibility;
/**
* Escape hatch for sandboxed cron access.
* - "off": never escape
* - "elevated": allow escape when session elevated != off
* - "elevated-full": allow escape only when elevated=full
*/
escape?: SandboxCronEscape;
/**
* Allow main-session cron jobs from sandboxed sessions.
* Default: false.
*/
allowMainSessionJobs?: boolean;
/**
* Delivery policy for sandboxed cron jobs.
* - "off": forbid deliver/to/channel
* - "last-only": allow deliver to last route only (no explicit to)
* - "explicit": allow explicit delivery targets
*/
delivery?: SandboxCronDelivery;
};

View File

@ -158,6 +158,19 @@ export const AgentDefaultsSchema = z
mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(),
workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(),
sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).optional(),
cron: z
.object({
visibility: z.union([z.literal("agent"), z.literal("all")]).optional(),
escape: z
.union([z.literal("off"), z.literal("elevated"), z.literal("elevated-full")])
.optional(),
allowMainSessionJobs: z.boolean().optional(),
delivery: z
.union([z.literal("off"), z.literal("last-only"), z.literal("explicit")])
.optional(),
})
.strict()
.optional(),
scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),

View File

@ -234,6 +234,19 @@ export const AgentSandboxSchema = z
mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(),
workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(),
sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).optional(),
cron: z
.object({
visibility: z.union([z.literal("agent"), z.literal("all")]).optional(),
escape: z
.union([z.literal("off"), z.literal("elevated"), z.literal("elevated-full")])
.optional(),
allowMainSessionJobs: z.boolean().optional(),
delivery: z
.union([z.literal("off"), z.literal("last-only"), z.literal("explicit")])
.optional(),
})
.strict()
.optional(),
scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),

View File

@ -122,6 +122,7 @@ export const CronJobSchema = Type.Object(
export const CronListParamsSchema = Type.Object(
{
includeDisabled: Type.Optional(Type.Boolean()),
actorAgentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
@ -132,6 +133,7 @@ export const CronAddParamsSchema = Type.Object(
{
name: NonEmptyString,
agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
actorAgentId: Type.Optional(NonEmptyString),
description: Type.Optional(Type.String()),
enabled: Type.Optional(Type.Boolean()),
deleteAfterRun: Type.Optional(Type.Boolean()),
@ -166,6 +168,7 @@ export const CronUpdateParamsSchema = Type.Union([
{
id: NonEmptyString,
patch: CronJobPatchSchema,
actorAgentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
@ -173,6 +176,7 @@ export const CronUpdateParamsSchema = Type.Union([
{
jobId: NonEmptyString,
patch: CronJobPatchSchema,
actorAgentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
@ -182,12 +186,14 @@ export const CronRemoveParamsSchema = Type.Union([
Type.Object(
{
id: NonEmptyString,
actorAgentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
Type.Object(
{
jobId: NonEmptyString,
actorAgentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
@ -198,6 +204,7 @@ export const CronRunParamsSchema = Type.Union([
{
id: NonEmptyString,
mode: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("force")])),
actorAgentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
@ -205,6 +212,7 @@ export const CronRunParamsSchema = Type.Union([
{
jobId: NonEmptyString,
mode: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("force")])),
actorAgentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
@ -215,6 +223,7 @@ export const CronRunsParamsSchema = Type.Union([
{
id: NonEmptyString,
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })),
actorAgentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
@ -222,6 +231,7 @@ export const CronRunsParamsSchema = Type.Union([
{
jobId: NonEmptyString,
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })),
actorAgentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),

View File

@ -1,6 +1,7 @@
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
import { readCronRunLogEntries, resolveCronRunLogPath } from "../../cron/run-log.js";
import type { CronJobCreate, CronJobPatch } from "../../cron/types.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import {
ErrorCodes,
errorShape,
@ -16,6 +17,30 @@ import {
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
function normalizeActorAgentId(params: unknown): string | undefined {
if (!params || typeof params !== "object") return undefined;
const raw = (params as { actorAgentId?: unknown }).actorAgentId;
if (typeof raw !== "string") return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
return normalizeAgentId(trimmed);
}
async function ensureActorJobVisible(params: {
context: Parameters<GatewayRequestHandlers["cron.list"]>[0]["context"];
actorAgentId?: string;
jobId: string;
}) {
if (!params.actorAgentId) return true;
const jobs = await params.context.cron.list({ includeDisabled: true });
return jobs.some(
(job) =>
job.id === params.jobId &&
typeof job.agentId === "string" &&
normalizeAgentId(job.agentId) === params.actorAgentId,
);
}
export const cronHandlers: GatewayRequestHandlers = {
wake: ({ params, respond, context }) => {
if (!validateWakeParams(params)) {
@ -48,11 +73,18 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const p = params as { includeDisabled?: boolean };
const p = params as { includeDisabled?: boolean; actorAgentId?: string };
const actorAgentId = normalizeActorAgentId(p);
const jobs = await context.cron.list({
includeDisabled: p.includeDisabled,
});
respond(true, { jobs }, undefined);
const filtered = actorAgentId
? jobs.filter(
(job) =>
typeof job.agentId === "string" && normalizeAgentId(job.agentId) === actorAgentId,
)
: jobs;
respond(true, { jobs: filtered }, undefined);
},
"cron.status": async ({ params, respond, context }) => {
if (!validateCronStatusParams(params)) {
@ -71,6 +103,28 @@ export const cronHandlers: GatewayRequestHandlers = {
},
"cron.add": async ({ params, respond, context }) => {
const normalized = normalizeCronJobCreate(params) ?? params;
const actorAgentId = normalizeActorAgentId(normalized);
if (actorAgentId && normalized && typeof normalized === "object") {
const record = normalized as Record<string, unknown>;
const agentIdRaw = record.agentId;
if (agentIdRaw === null || agentIdRaw === undefined) {
record.agentId = actorAgentId;
} else if (typeof agentIdRaw === "string") {
const normalizedAgent = normalizeAgentId(agentIdRaw);
if (normalizedAgent !== actorAgentId) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "cron agentId is not visible to this actor"),
);
return;
}
record.agentId = actorAgentId;
} else {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron agentId"));
return;
}
}
if (!validateCronAddParams(normalized)) {
respond(
false,
@ -106,6 +160,7 @@ export const cronHandlers: GatewayRequestHandlers = {
id?: string;
jobId?: string;
patch: Record<string, unknown>;
actorAgentId?: string;
};
const jobId = p.id ?? p.jobId;
if (!jobId) {
@ -116,6 +171,15 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const actorAgentId = normalizeActorAgentId(p);
if (actorAgentId && !(await ensureActorJobVisible({ context, actorAgentId, jobId }))) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "cron job not visible to this actor"),
);
return;
}
const job = await context.cron.update(jobId, p.patch as unknown as CronJobPatch);
respond(true, job, undefined);
},
@ -131,7 +195,7 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const p = params as { id?: string; jobId?: string };
const p = params as { id?: string; jobId?: string; actorAgentId?: string };
const jobId = p.id ?? p.jobId;
if (!jobId) {
respond(
@ -141,6 +205,15 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const actorAgentId = normalizeActorAgentId(p);
if (actorAgentId && !(await ensureActorJobVisible({ context, actorAgentId, jobId }))) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "cron job not visible to this actor"),
);
return;
}
const result = await context.cron.remove(jobId);
respond(true, result, undefined);
},
@ -156,7 +229,12 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const p = params as { id?: string; jobId?: string; mode?: "due" | "force" };
const p = params as {
id?: string;
jobId?: string;
mode?: "due" | "force";
actorAgentId?: string;
};
const jobId = p.id ?? p.jobId;
if (!jobId) {
respond(
@ -166,6 +244,15 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const actorAgentId = normalizeActorAgentId(p);
if (actorAgentId && !(await ensureActorJobVisible({ context, actorAgentId, jobId }))) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "cron job not visible to this actor"),
);
return;
}
const result = await context.cron.run(jobId, p.mode);
respond(true, result, undefined);
},
@ -181,7 +268,7 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const p = params as { id?: string; jobId?: string; limit?: number };
const p = params as { id?: string; jobId?: string; limit?: number; actorAgentId?: string };
const jobId = p.id ?? p.jobId;
if (!jobId) {
respond(
@ -191,6 +278,15 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const actorAgentId = normalizeActorAgentId(p);
if (actorAgentId && !(await ensureActorJobVisible({ context, actorAgentId, jobId }))) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "cron job not visible to this actor"),
);
return;
}
const logPath = resolveCronRunLogPath({
storePath: context.cronStorePath,
jobId,

View File

@ -359,4 +359,138 @@ describe("gateway server cron", () => {
}
}
}, 45_000);
test("scopes cron jobs with actorAgentId", async () => {
const prevSkipCron = process.env.CLAWDBOT_SKIP_CRON;
process.env.CLAWDBOT_SKIP_CRON = "0";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-actor-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.sessionConfig = { mainKey: "primary" };
testState.cronEnabled = false;
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
try {
const alphaRes = await rpcReq(ws, "cron.add", {
name: "alpha",
enabled: true,
actorAgentId: " ALPHA ",
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "alpha" },
});
expect(alphaRes.ok).toBe(true);
const alphaPayload = alphaRes.payload as { id?: unknown; agentId?: unknown } | null;
const alphaJobIdValue = alphaPayload?.id;
const alphaJobId = typeof alphaJobIdValue === "string" ? alphaJobIdValue : "";
expect(alphaJobId.length > 0).toBe(true);
expect(alphaPayload?.agentId).toBe("alpha");
const betaRes = await rpcReq(ws, "cron.add", {
name: "beta",
enabled: true,
agentId: "beta",
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "beta" },
});
expect(betaRes.ok).toBe(true);
const betaJobIdValue = (betaRes.payload as { id?: unknown } | null)?.id;
const betaJobId = typeof betaJobIdValue === "string" ? betaJobIdValue : "";
expect(betaJobId.length > 0).toBe(true);
const alphaNullRes = await rpcReq(ws, "cron.add", {
name: "alpha-null",
enabled: true,
actorAgentId: "alpha",
agentId: null,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "alpha-null" },
});
expect(alphaNullRes.ok).toBe(true);
const alphaNullPayload = alphaNullRes.payload as { agentId?: unknown } | null;
expect(alphaNullPayload?.agentId).toBe("alpha");
const listAll = await rpcReq(ws, "cron.list", { includeDisabled: true });
expect(listAll.ok).toBe(true);
const allJobs = (listAll.payload as { jobs?: unknown } | null)?.jobs;
expect(Array.isArray(allJobs)).toBe(true);
expect((allJobs as unknown[]).length).toBe(3);
const listAlpha = await rpcReq(ws, "cron.list", {
includeDisabled: true,
actorAgentId: "alpha",
});
expect(listAlpha.ok).toBe(true);
const alphaJobs = (listAlpha.payload as { jobs?: unknown } | null)?.jobs;
expect(Array.isArray(alphaJobs)).toBe(true);
expect((alphaJobs as unknown[]).length).toBe(2);
expect(
(alphaJobs as Array<{ agentId?: unknown }>).every((job) => job.agentId === "alpha"),
).toBe(true);
const addMismatch = await rpcReq(ws, "cron.add", {
name: "mismatch",
enabled: true,
agentId: "beta",
actorAgentId: "alpha",
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "mismatch" },
});
expect(addMismatch.ok).toBe(false);
expect(addMismatch.error?.message ?? "").toContain("not visible");
const updateWrong = await rpcReq(ws, "cron.update", {
id: betaJobId,
actorAgentId: "alpha",
patch: { name: "oops" },
});
expect(updateWrong.ok).toBe(false);
expect(updateWrong.error?.message ?? "").toContain("not visible");
const runWrong = await rpcReq(ws, "cron.run", {
id: betaJobId,
mode: "force",
actorAgentId: "alpha",
});
expect(runWrong.ok).toBe(false);
expect(runWrong.error?.message ?? "").toContain("not visible");
const removeWrong = await rpcReq(ws, "cron.remove", {
id: betaJobId,
actorAgentId: "alpha",
});
expect(removeWrong.ok).toBe(false);
expect(removeWrong.error?.message ?? "").toContain("not visible");
const runsWrong = await rpcReq(ws, "cron.runs", {
id: betaJobId,
limit: 5,
actorAgentId: "alpha",
});
expect(runsWrong.ok).toBe(false);
expect(runsWrong.error?.message ?? "").toContain("not visible");
} finally {
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
testState.sessionConfig = undefined;
testState.cronEnabled = undefined;
if (prevSkipCron === undefined) {
delete process.env.CLAWDBOT_SKIP_CRON;
} else {
process.env.CLAWDBOT_SKIP_CRON = prevSkipCron;
}
}
});
});

View File

@ -3,6 +3,31 @@ import { afterAll, afterEach, beforeEach, vi } from "vitest";
// Ensure Vitest environment is properly set
process.env.VITEST = "true";
// Optional dependency in e2e boot path; mock to avoid module resolution failures.
vi.mock("node-edge-tts", () => ({
EdgeTTS: class {
async ttsPromise(_text: string, _outputPath: string) {
return;
}
},
}));
vi.mock("@line/bot-sdk", () => ({
messagingApi: {
MessagingApiClient: class {
constructor(_opts: unknown) {}
async getBotInfo() {
return {
displayName: "mock",
userId: "mock",
basicId: "mock",
pictureUrl: "mock",
};
}
},
},
}));
import type {
ChannelId,
ChannelOutboundAdapter,