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 });
|
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 {
|
export {
|
||||||
resolveSandboxBrowserConfig,
|
resolveSandboxBrowserConfig,
|
||||||
resolveSandboxConfigForAgent,
|
resolveSandboxConfigForAgent,
|
||||||
|
resolveSandboxCronConfig,
|
||||||
resolveSandboxDockerConfig,
|
resolveSandboxDockerConfig,
|
||||||
resolveSandboxPruneConfig,
|
resolveSandboxPruneConfig,
|
||||||
resolveSandboxScope,
|
resolveSandboxScope,
|
||||||
@ -32,6 +33,7 @@ export type {
|
|||||||
SandboxBrowserConfig,
|
SandboxBrowserConfig,
|
||||||
SandboxBrowserContext,
|
SandboxBrowserContext,
|
||||||
SandboxConfig,
|
SandboxConfig,
|
||||||
|
SandboxCronPolicy,
|
||||||
SandboxContext,
|
SandboxContext,
|
||||||
SandboxDockerConfig,
|
SandboxDockerConfig,
|
||||||
SandboxPruneConfig,
|
SandboxPruneConfig,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
DEFAULT_SANDBOX_IDLE_HOURS,
|
DEFAULT_SANDBOX_IDLE_HOURS,
|
||||||
DEFAULT_SANDBOX_IMAGE,
|
DEFAULT_SANDBOX_IMAGE,
|
||||||
DEFAULT_SANDBOX_MAX_AGE_DAYS,
|
DEFAULT_SANDBOX_MAX_AGE_DAYS,
|
||||||
|
DEFAULT_SANDBOX_CRON_POLICY,
|
||||||
DEFAULT_SANDBOX_WORKDIR,
|
DEFAULT_SANDBOX_WORKDIR,
|
||||||
DEFAULT_SANDBOX_WORKSPACE_ROOT,
|
DEFAULT_SANDBOX_WORKSPACE_ROOT,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
@ -21,6 +22,7 @@ import type {
|
|||||||
SandboxDockerConfig,
|
SandboxDockerConfig,
|
||||||
SandboxPruneConfig,
|
SandboxPruneConfig,
|
||||||
SandboxScope,
|
SandboxScope,
|
||||||
|
SandboxCronPolicy,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
export function resolveSandboxScope(params: {
|
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 {
|
export function resolveSandboxConfigForAgent(cfg?: MoltbotConfig, agentId?: string): SandboxConfig {
|
||||||
const agent = cfg?.agents?.defaults?.sandbox;
|
const agent = cfg?.agents?.defaults?.sandbox;
|
||||||
|
|
||||||
@ -163,5 +183,9 @@ export function resolveSandboxConfigForAgent(cfg?: MoltbotConfig, agentId?: stri
|
|||||||
globalPrune: agent?.prune,
|
globalPrune: agent?.prune,
|
||||||
agentPrune: agentSandbox?.prune,
|
agentPrune: agentSandbox?.prune,
|
||||||
}),
|
}),
|
||||||
|
cron: resolveSandboxCronConfig({
|
||||||
|
globalCron: agent?.cron,
|
||||||
|
agentCron: agentSandbox?.cron,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,9 +37,15 @@ export const DEFAULT_TOOL_DENY = [
|
|||||||
...CHANNEL_IDS,
|
...CHANNEL_IDS,
|
||||||
] as const;
|
] 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_BROWSER_IMAGE = "moltbot-sandbox-browser:bookworm-slim";
|
||||||
export const DEFAULT_SANDBOX_COMMON_IMAGE = "moltbot-sandbox-common: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_PREFIX = "moltbot-sbx-browser-";
|
||||||
export const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222;
|
export const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222;
|
||||||
export const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900;
|
export const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900;
|
||||||
|
|||||||
@ -48,6 +48,13 @@ export type SandboxPruneConfig = {
|
|||||||
|
|
||||||
export type SandboxScope = "session" | "agent" | "shared";
|
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 = {
|
export type SandboxConfig = {
|
||||||
mode: "off" | "non-main" | "all";
|
mode: "off" | "non-main" | "all";
|
||||||
scope: SandboxScope;
|
scope: SandboxScope;
|
||||||
@ -57,6 +64,7 @@ export type SandboxConfig = {
|
|||||||
browser: SandboxBrowserConfig;
|
browser: SandboxBrowserConfig;
|
||||||
tools: SandboxToolPolicy;
|
tools: SandboxToolPolicy;
|
||||||
prune: SandboxPruneConfig;
|
prune: SandboxPruneConfig;
|
||||||
|
cron: SandboxCronPolicy;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SandboxBrowserContext = {
|
export type SandboxBrowserContext = {
|
||||||
|
|||||||
@ -1,12 +1,34 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const callGatewayMock = vi.fn();
|
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", () => ({
|
vi.mock("../../gateway/call.js", () => ({
|
||||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../agent-scope.js", () => ({
|
vi.mock("../sandbox/runtime-status.js", () => ({
|
||||||
resolveSessionAgentId: () => "agent-123",
|
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";
|
import { createCronTool } from "./cron-tool.js";
|
||||||
@ -15,6 +37,21 @@ describe("cron tool", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
callGatewayMock.mockReset();
|
callGatewayMock.mockReset();
|
||||||
callGatewayMock.mockResolvedValue({ ok: true });
|
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([
|
it.each([
|
||||||
@ -231,4 +268,535 @@ describe("cron tool", () => {
|
|||||||
expect(call.method).toBe("cron.add");
|
expect(call.method).toBe("cron.add");
|
||||||
expect(call.params?.agentId).toBeNull();
|
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 { Type } from "@sinclair/typebox";
|
||||||
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
|
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
|
||||||
import { loadConfig } from "../../config/config.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 { truncateUtf16Safe } from "../../utils.js";
|
||||||
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
||||||
import { resolveSessionAgentId } from "../agent-scope.js";
|
|
||||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||||
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
||||||
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
||||||
@ -44,6 +48,19 @@ type CronToolOptions = {
|
|||||||
agentSessionKey?: string;
|
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 = {
|
type ChatMessage = {
|
||||||
role?: unknown;
|
role?: unknown;
|
||||||
content?: unknown;
|
content?: unknown;
|
||||||
@ -87,6 +104,143 @@ function extractMessageText(message: ChatMessage): { role: string; text: string
|
|||||||
return joined ? { role, text: joined } : null;
|
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: {
|
async function buildReminderContextLines(params: {
|
||||||
agentSessionKey?: string;
|
agentSessionKey?: string;
|
||||||
gatewayOpts: GatewayCallOptions;
|
gatewayOpts: GatewayCallOptions;
|
||||||
@ -181,6 +335,13 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
|
|||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const params = args as Record<string, unknown>;
|
const params = args as Record<string, unknown>;
|
||||||
const action = readStringParam(params, "action", { required: true });
|
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 = {
|
const gatewayOpts: GatewayCallOptions = {
|
||||||
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
|
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
|
||||||
gatewayToken: readStringParam(params, "gatewayToken", { 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(
|
return jsonResult(
|
||||||
await callGatewayTool("cron.list", gatewayOpts, {
|
await callGatewayTool("cron.list", gatewayOpts, {
|
||||||
includeDisabled: Boolean(params.includeDisabled),
|
includeDisabled: Boolean(params.includeDisabled),
|
||||||
|
...(scopedAgentId ? { actorAgentId: scopedAgentId } : {}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
case "add": {
|
case "add": {
|
||||||
@ -201,13 +363,31 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
|
|||||||
throw new Error("job required");
|
throw new Error("job required");
|
||||||
}
|
}
|
||||||
const job = normalizeCronJobCreate(params.job) ?? params.job;
|
const job = normalizeCronJobCreate(params.job) ?? params.job;
|
||||||
if (job && typeof job === "object" && !("agentId" in job)) {
|
if (job && typeof job === "object") {
|
||||||
const cfg = loadConfig();
|
const jobRecord = job as Record<string, unknown>;
|
||||||
const agentId = opts?.agentSessionKey
|
const sessionTarget = jobSessionTarget(jobRecord);
|
||||||
? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg })
|
const payload = jobPayload(jobRecord);
|
||||||
: undefined;
|
if (access.restricted && !access.policy.allowMainSessionJobs) {
|
||||||
if (agentId) {
|
if (sessionTarget === "main") {
|
||||||
(job as { agentId?: string }).agentId = agentId;
|
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 =
|
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": {
|
case "update": {
|
||||||
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
|
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");
|
throw new Error("patch required");
|
||||||
}
|
}
|
||||||
const patch = normalizeCronJobPatch(params.patch) ?? params.patch;
|
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(
|
return jsonResult(
|
||||||
await callGatewayTool("cron.update", gatewayOpts, {
|
await callGatewayTool("cron.update", gatewayOpts, {
|
||||||
id,
|
id,
|
||||||
patch,
|
patch,
|
||||||
|
...(scopedAgentId ? { actorAgentId: scopedAgentId } : {}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -256,21 +468,71 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
|
|||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
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": {
|
case "run": {
|
||||||
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
|
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
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": {
|
case "runs": {
|
||||||
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
|
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
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": {
|
case "wake": {
|
||||||
const text = readStringParam(params, "text", { required: true });
|
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 === "now" || params.mode === "next-heartbeat"
|
||||||
? params.mode
|
? params.mode
|
||||||
: "next-heartbeat";
|
: "next-heartbeat";
|
||||||
|
if (access.restricted && !access.policy.allowMainSessionJobs) {
|
||||||
|
throw new Error("sandboxed cron cannot send wake events to main sessions");
|
||||||
|
}
|
||||||
return jsonResult(
|
return jsonResult(
|
||||||
await callGatewayTool("wake", gatewayOpts, { mode, text }, { expectFinal: false }),
|
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 { ChannelId } from "../channels/plugins/types.js";
|
||||||
import type {
|
import type {
|
||||||
SandboxBrowserSettings,
|
SandboxBrowserSettings,
|
||||||
|
SandboxCronSettings,
|
||||||
SandboxDockerSettings,
|
SandboxDockerSettings,
|
||||||
SandboxPruneSettings,
|
SandboxPruneSettings,
|
||||||
} from "./types.sandbox.js";
|
} from "./types.sandbox.js";
|
||||||
@ -234,6 +235,8 @@ export type AgentDefaultsConfig = {
|
|||||||
browser?: SandboxBrowserSettings;
|
browser?: SandboxBrowserSettings;
|
||||||
/** Auto-prune sandbox containers. */
|
/** Auto-prune sandbox containers. */
|
||||||
prune?: SandboxPruneSettings;
|
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 { GroupChatConfig } from "./types.messages.js";
|
||||||
import type {
|
import type {
|
||||||
SandboxBrowserSettings,
|
SandboxBrowserSettings,
|
||||||
|
SandboxCronSettings,
|
||||||
SandboxDockerSettings,
|
SandboxDockerSettings,
|
||||||
SandboxPruneSettings,
|
SandboxPruneSettings,
|
||||||
} from "./types.sandbox.js";
|
} from "./types.sandbox.js";
|
||||||
@ -58,6 +59,8 @@ export type AgentConfig = {
|
|||||||
browser?: SandboxBrowserSettings;
|
browser?: SandboxBrowserSettings;
|
||||||
/** Auto-prune overrides for this agent. */
|
/** Auto-prune overrides for this agent. */
|
||||||
prune?: SandboxPruneSettings;
|
prune?: SandboxPruneSettings;
|
||||||
|
/** Cron access policy for sandboxed sessions. */
|
||||||
|
cron?: SandboxCronSettings;
|
||||||
};
|
};
|
||||||
tools?: AgentToolsConfig;
|
tools?: AgentToolsConfig;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -73,3 +73,35 @@ export type SandboxPruneSettings = {
|
|||||||
/** Prune if older than N days (0 disables). */
|
/** Prune if older than N days (0 disables). */
|
||||||
maxAgeDays?: number;
|
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(),
|
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(),
|
workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(),
|
||||||
sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).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(),
|
scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(),
|
||||||
perSession: z.boolean().optional(),
|
perSession: z.boolean().optional(),
|
||||||
workspaceRoot: z.string().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(),
|
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(),
|
workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(),
|
||||||
sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).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(),
|
scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(),
|
||||||
perSession: z.boolean().optional(),
|
perSession: z.boolean().optional(),
|
||||||
workspaceRoot: z.string().optional(),
|
workspaceRoot: z.string().optional(),
|
||||||
|
|||||||
@ -122,6 +122,7 @@ export const CronJobSchema = Type.Object(
|
|||||||
export const CronListParamsSchema = Type.Object(
|
export const CronListParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
includeDisabled: Type.Optional(Type.Boolean()),
|
includeDisabled: Type.Optional(Type.Boolean()),
|
||||||
|
actorAgentId: Type.Optional(NonEmptyString),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
@ -132,6 +133,7 @@ export const CronAddParamsSchema = Type.Object(
|
|||||||
{
|
{
|
||||||
name: NonEmptyString,
|
name: NonEmptyString,
|
||||||
agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
|
actorAgentId: Type.Optional(NonEmptyString),
|
||||||
description: Type.Optional(Type.String()),
|
description: Type.Optional(Type.String()),
|
||||||
enabled: Type.Optional(Type.Boolean()),
|
enabled: Type.Optional(Type.Boolean()),
|
||||||
deleteAfterRun: Type.Optional(Type.Boolean()),
|
deleteAfterRun: Type.Optional(Type.Boolean()),
|
||||||
@ -166,6 +168,7 @@ export const CronUpdateParamsSchema = Type.Union([
|
|||||||
{
|
{
|
||||||
id: NonEmptyString,
|
id: NonEmptyString,
|
||||||
patch: CronJobPatchSchema,
|
patch: CronJobPatchSchema,
|
||||||
|
actorAgentId: Type.Optional(NonEmptyString),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
@ -173,6 +176,7 @@ export const CronUpdateParamsSchema = Type.Union([
|
|||||||
{
|
{
|
||||||
jobId: NonEmptyString,
|
jobId: NonEmptyString,
|
||||||
patch: CronJobPatchSchema,
|
patch: CronJobPatchSchema,
|
||||||
|
actorAgentId: Type.Optional(NonEmptyString),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
@ -182,12 +186,14 @@ export const CronRemoveParamsSchema = Type.Union([
|
|||||||
Type.Object(
|
Type.Object(
|
||||||
{
|
{
|
||||||
id: NonEmptyString,
|
id: NonEmptyString,
|
||||||
|
actorAgentId: Type.Optional(NonEmptyString),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
Type.Object(
|
Type.Object(
|
||||||
{
|
{
|
||||||
jobId: NonEmptyString,
|
jobId: NonEmptyString,
|
||||||
|
actorAgentId: Type.Optional(NonEmptyString),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
@ -198,6 +204,7 @@ export const CronRunParamsSchema = Type.Union([
|
|||||||
{
|
{
|
||||||
id: NonEmptyString,
|
id: NonEmptyString,
|
||||||
mode: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("force")])),
|
mode: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("force")])),
|
||||||
|
actorAgentId: Type.Optional(NonEmptyString),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
@ -205,6 +212,7 @@ export const CronRunParamsSchema = Type.Union([
|
|||||||
{
|
{
|
||||||
jobId: NonEmptyString,
|
jobId: NonEmptyString,
|
||||||
mode: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("force")])),
|
mode: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("force")])),
|
||||||
|
actorAgentId: Type.Optional(NonEmptyString),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
@ -215,6 +223,7 @@ export const CronRunsParamsSchema = Type.Union([
|
|||||||
{
|
{
|
||||||
id: NonEmptyString,
|
id: NonEmptyString,
|
||||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })),
|
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })),
|
||||||
|
actorAgentId: Type.Optional(NonEmptyString),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
@ -222,6 +231,7 @@ export const CronRunsParamsSchema = Type.Union([
|
|||||||
{
|
{
|
||||||
jobId: NonEmptyString,
|
jobId: NonEmptyString,
|
||||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })),
|
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })),
|
||||||
|
actorAgentId: Type.Optional(NonEmptyString),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
|
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
|
||||||
import { readCronRunLogEntries, resolveCronRunLogPath } from "../../cron/run-log.js";
|
import { readCronRunLogEntries, resolveCronRunLogPath } from "../../cron/run-log.js";
|
||||||
import type { CronJobCreate, CronJobPatch } from "../../cron/types.js";
|
import type { CronJobCreate, CronJobPatch } from "../../cron/types.js";
|
||||||
|
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
errorShape,
|
errorShape,
|
||||||
@ -16,6 +17,30 @@ import {
|
|||||||
} from "../protocol/index.js";
|
} from "../protocol/index.js";
|
||||||
import type { GatewayRequestHandlers } from "./types.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 = {
|
export const cronHandlers: GatewayRequestHandlers = {
|
||||||
wake: ({ params, respond, context }) => {
|
wake: ({ params, respond, context }) => {
|
||||||
if (!validateWakeParams(params)) {
|
if (!validateWakeParams(params)) {
|
||||||
@ -48,11 +73,18 @@ export const cronHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
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({
|
const jobs = await context.cron.list({
|
||||||
includeDisabled: p.includeDisabled,
|
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 }) => {
|
"cron.status": async ({ params, respond, context }) => {
|
||||||
if (!validateCronStatusParams(params)) {
|
if (!validateCronStatusParams(params)) {
|
||||||
@ -71,6 +103,28 @@ export const cronHandlers: GatewayRequestHandlers = {
|
|||||||
},
|
},
|
||||||
"cron.add": async ({ params, respond, context }) => {
|
"cron.add": async ({ params, respond, context }) => {
|
||||||
const normalized = normalizeCronJobCreate(params) ?? params;
|
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)) {
|
if (!validateCronAddParams(normalized)) {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
@ -106,6 +160,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
jobId?: string;
|
jobId?: string;
|
||||||
patch: Record<string, unknown>;
|
patch: Record<string, unknown>;
|
||||||
|
actorAgentId?: string;
|
||||||
};
|
};
|
||||||
const jobId = p.id ?? p.jobId;
|
const jobId = p.id ?? p.jobId;
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
@ -116,6 +171,15 @@ export const cronHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
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);
|
const job = await context.cron.update(jobId, p.patch as unknown as CronJobPatch);
|
||||||
respond(true, job, undefined);
|
respond(true, job, undefined);
|
||||||
},
|
},
|
||||||
@ -131,7 +195,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
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;
|
const jobId = p.id ?? p.jobId;
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
respond(
|
respond(
|
||||||
@ -141,6 +205,15 @@ export const cronHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
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);
|
const result = await context.cron.remove(jobId);
|
||||||
respond(true, result, undefined);
|
respond(true, result, undefined);
|
||||||
},
|
},
|
||||||
@ -156,7 +229,12 @@ export const cronHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
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;
|
const jobId = p.id ?? p.jobId;
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
respond(
|
respond(
|
||||||
@ -166,6 +244,15 @@ export const cronHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
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);
|
const result = await context.cron.run(jobId, p.mode);
|
||||||
respond(true, result, undefined);
|
respond(true, result, undefined);
|
||||||
},
|
},
|
||||||
@ -181,7 +268,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
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;
|
const jobId = p.id ?? p.jobId;
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
respond(
|
respond(
|
||||||
@ -191,6 +278,15 @@ export const cronHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
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({
|
const logPath = resolveCronRunLogPath({
|
||||||
storePath: context.cronStorePath,
|
storePath: context.cronStorePath,
|
||||||
jobId,
|
jobId,
|
||||||
|
|||||||
@ -359,4 +359,138 @@ describe("gateway server cron", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 45_000);
|
}, 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
|
// Ensure Vitest environment is properly set
|
||||||
process.env.VITEST = "true";
|
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 {
|
import type {
|
||||||
ChannelId,
|
ChannelId,
|
||||||
ChannelOutboundAdapter,
|
ChannelOutboundAdapter,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user