Files
openclaw/src/tasks/task-registry.store.test.ts
Vincent Koc 7cd0ff2d88 refactor(tasks): add owner-key task access boundaries (#58516)
* refactor(tasks): add owner-key task access boundaries

* test(acp): update task owner-key assertion

* fix(tasks): align owner key checks and migration scope
2026-04-01 03:12:33 +09:00

256 lines
7.3 KiB
TypeScript

import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import {
createTaskRecord,
deleteTaskRecordById,
findTaskByRunId,
resetTaskRegistryForTests,
} from "./task-registry.js";
import { resolveTaskRegistryDir, resolveTaskRegistrySqlitePath } from "./task-registry.paths.js";
import { configureTaskRegistryRuntime, type TaskRegistryHookEvent } from "./task-registry.store.js";
import type { TaskRecord } from "./task-registry.types.js";
function createStoredTask(): TaskRecord {
return {
taskId: "task-restored",
runtime: "acp",
sourceId: "run-restored",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:codex:acp:restored",
runId: "run-restored",
task: "Restored task",
status: "running",
deliveryStatus: "pending",
notifyPolicy: "done_only",
createdAt: 100,
lastEventAt: 100,
};
}
describe("task-registry store runtime", () => {
afterEach(() => {
delete process.env.OPENCLAW_STATE_DIR;
resetTaskRegistryForTests();
});
it("uses the configured task store for restore and save", () => {
const storedTask = createStoredTask();
const loadSnapshot = vi.fn(() => ({
tasks: new Map([[storedTask.taskId, storedTask]]),
deliveryStates: new Map(),
}));
const saveSnapshot = vi.fn();
configureTaskRegistryRuntime({
store: {
loadSnapshot,
saveSnapshot,
},
});
expect(findTaskByRunId("run-restored")).toMatchObject({
taskId: "task-restored",
task: "Restored task",
});
expect(loadSnapshot).toHaveBeenCalledTimes(1);
createTaskRecord({
runtime: "acp",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:codex:acp:new",
runId: "run-new",
task: "New task",
status: "running",
deliveryStatus: "pending",
});
expect(saveSnapshot).toHaveBeenCalled();
const latestSnapshot = saveSnapshot.mock.calls.at(-1)?.[0] as {
tasks: ReadonlyMap<string, TaskRecord>;
};
expect(latestSnapshot.tasks.size).toBe(2);
expect(latestSnapshot.tasks.get("task-restored")?.task).toBe("Restored task");
});
it("emits incremental hook events for restore, mutation, and delete", () => {
const events: TaskRegistryHookEvent[] = [];
configureTaskRegistryRuntime({
store: {
loadSnapshot: () => ({
tasks: new Map([[createStoredTask().taskId, createStoredTask()]]),
deliveryStates: new Map(),
}),
saveSnapshot: () => {},
},
hooks: {
onEvent: (event) => {
events.push(event);
},
},
});
expect(findTaskByRunId("run-restored")).toBeTruthy();
const created = createTaskRecord({
runtime: "acp",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:codex:acp:new",
runId: "run-new",
task: "New task",
status: "running",
deliveryStatus: "pending",
});
expect(deleteTaskRecordById(created.taskId)).toBe(true);
expect(events.map((event) => event.kind)).toEqual(["restored", "upserted", "deleted"]);
expect(events[0]).toMatchObject({
kind: "restored",
tasks: [expect.objectContaining({ taskId: "task-restored" })],
});
expect(events[1]).toMatchObject({
kind: "upserted",
task: expect.objectContaining({ taskId: created.taskId }),
});
expect(events[2]).toMatchObject({
kind: "deleted",
taskId: created.taskId,
});
});
it("restores persisted tasks from the default sqlite store", () => {
const created = createTaskRecord({
runtime: "cron",
ownerKey: "agent:main:main",
scopeKind: "session",
sourceId: "job-123",
runId: "run-sqlite",
task: "Run nightly cron",
status: "running",
deliveryStatus: "not_applicable",
notifyPolicy: "silent",
});
resetTaskRegistryForTests({ persist: false });
expect(findTaskByRunId("run-sqlite")).toMatchObject({
taskId: created.taskId,
sourceId: "job-123",
task: "Run nightly cron",
});
});
it("hardens the sqlite task store directory and file modes", () => {
if (process.platform === "win32") {
return;
}
const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-task-store-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
createTaskRecord({
runtime: "cron",
ownerKey: "agent:main:main",
scopeKind: "session",
sourceId: "job-456",
runId: "run-perms",
task: "Run secured cron",
status: "running",
deliveryStatus: "not_applicable",
notifyPolicy: "silent",
});
const registryDir = resolveTaskRegistryDir(process.env);
const sqlitePath = resolveTaskRegistrySqlitePath(process.env);
expect(statSync(registryDir).mode & 0o777).toBe(0o700);
expect(statSync(sqlitePath).mode & 0o777).toBe(0o600);
resetTaskRegistryForTests();
rmSync(stateDir, { recursive: true, force: true });
});
it("migrates legacy ownerless cron rows to system scope", () => {
const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-task-store-legacy-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
const sqlitePath = resolveTaskRegistrySqlitePath(process.env);
mkdirSync(path.dirname(sqlitePath), { recursive: true });
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(sqlitePath);
db.exec(`
CREATE TABLE task_runs (
task_id TEXT PRIMARY KEY,
runtime TEXT NOT NULL,
source_id TEXT,
requester_session_key TEXT NOT NULL,
child_session_key TEXT,
parent_task_id TEXT,
agent_id TEXT,
run_id TEXT,
label TEXT,
task TEXT NOT NULL,
status TEXT NOT NULL,
delivery_status TEXT NOT NULL,
notify_policy TEXT NOT NULL,
created_at INTEGER NOT NULL,
started_at INTEGER,
ended_at INTEGER,
last_event_at INTEGER,
cleanup_after INTEGER,
error TEXT,
progress_summary TEXT,
terminal_summary TEXT,
terminal_outcome TEXT
);
`);
db.exec(`
CREATE TABLE task_delivery_state (
task_id TEXT PRIMARY KEY,
requester_origin_json TEXT,
last_notified_event_at INTEGER
);
`);
db.prepare(`
INSERT INTO task_runs (
task_id,
runtime,
source_id,
requester_session_key,
child_session_key,
run_id,
task,
status,
delivery_status,
notify_policy,
created_at,
last_event_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
"legacy-cron-task",
"cron",
"nightly-digest",
"",
"agent:main:cron:nightly-digest",
"legacy-cron-run",
"Nightly digest",
"running",
"not_applicable",
"silent",
100,
100,
);
db.close();
resetTaskRegistryForTests({ persist: false });
expect(findTaskByRunId("legacy-cron-run")).toMatchObject({
taskId: "legacy-cron-task",
ownerKey: "system:cron:nightly-digest",
scopeKind: "system",
deliveryStatus: "not_applicable",
notifyPolicy: "silent",
});
});
});