Feat(cron): add sandbox policy controls for visibility, escape, and delivery
This commit is contained in:
parent
640c8d1554
commit
fed475f9ac
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export {
|
||||
resolveSandboxBrowserConfig,
|
||||
resolveSandboxConfigForAgent,
|
||||
resolveSandboxCronConfig,
|
||||
resolveSandboxDockerConfig,
|
||||
resolveSandboxPruneConfig,
|
||||
resolveSandboxScope,
|
||||
@ -32,6 +33,7 @@ export type {
|
||||
SandboxBrowserConfig,
|
||||
SandboxBrowserContext,
|
||||
SandboxConfig,
|
||||
SandboxCronPolicy,
|
||||
SandboxContext,
|
||||
SandboxDockerConfig,
|
||||
SandboxPruneConfig,
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 }),
|
||||
);
|
||||
|
||||
85
src/config/config.sandbox-cron.test.ts
Normal file
85
src/config/config.sandbox-cron.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 },
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user