diff --git a/src/agents/sandbox-merge.test.ts b/src/agents/sandbox-merge.test.ts index 8f3c7807e..2c6c1750a 100644 --- a/src/agents/sandbox-merge.test.ts +++ b/src/agents/sandbox-merge.test.ts @@ -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); + }); }); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 8ac65795d..6c8ec594c 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -1,6 +1,7 @@ export { resolveSandboxBrowserConfig, resolveSandboxConfigForAgent, + resolveSandboxCronConfig, resolveSandboxDockerConfig, resolveSandboxPruneConfig, resolveSandboxScope, @@ -32,6 +33,7 @@ export type { SandboxBrowserConfig, SandboxBrowserContext, SandboxConfig, + SandboxCronPolicy, SandboxContext, SandboxDockerConfig, SandboxPruneConfig, diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index aa848c54b..e07f3752c 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -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; + agentCron?: Partial; +}): 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, + }), }; } diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index 6a844ecc7..df4049d97 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -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; diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index f27dfd715..1ae1f0f3f 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -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 = { diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index efab4535f..1d646c83f 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -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(); + }); }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 739b3ada3..972b3f71b 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -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; + 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) { + const raw = (job as { agentId?: unknown }).agentId; + if (typeof raw !== "string") return undefined; + return normalizeAgentId(raw); +} + +function jobSessionTarget(job: Record) { + const raw = (job as { sessionTarget?: unknown }).sessionTarget; + return typeof raw === "string" ? raw : undefined; +} + +function jobPayload(job: Record) { + const raw = (job as { payload?: unknown }).payload; + return typeof raw === "object" && raw !== null ? (raw as Record) : 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; +} + +async function assertMainSessionJobAllowed(params: { + access: CronSandboxAccess; + jobId: string; + gatewayOpts: GatewayCallOptions; + scopedAgentId?: string; + jobRecord?: Record; +}) { + 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 | 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; 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; + 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), + ...(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; + 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 }), ); diff --git a/src/config/config.sandbox-cron.test.ts b/src/config/config.sandbox-cron.test.ts new file mode 100644 index 000000000..41ac5fe18 --- /dev/null +++ b/src/config/config.sandbox-cron.test.ts @@ -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); + } + }); +}); diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 9c6ce0211..4c8075115 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -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; }; }; diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index f083c1897..d9f2941c9 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -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; }; diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 4f5f83810..17d4e04a9 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -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; +}; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index a849078ed..697939db3 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -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(), diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 7a63e307d..dd95ef72d 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -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(), diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 63ed0c209..ba09a4eec 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -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 }, ), diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 4420358b8..906c68b0c 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -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[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; + 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; + 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, diff --git a/src/gateway/server.cron.e2e.test.ts b/src/gateway/server.cron.e2e.test.ts index f63efdd94..960e197ba 100644 --- a/src/gateway/server.cron.e2e.test.ts +++ b/src/gateway/server.cron.e2e.test.ts @@ -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; + } + } + }); }); diff --git a/test/setup.ts b/test/setup.ts index 3d2e3ecff..72fc07b2f 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -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,