import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
type RegisterGatewayMethodsOpts,
type SessionArchive,
registerGatewayMethods,
} from "./gateway-methods.js";
function makeHarness(extra: Partial<RegisterGatewayMethodsOpts> = {}) {
const rpc = {
ping: vi.fn().mockResolvedValue({ version: "0.0", pgConnected: true, mountCount: 0 }),
list: vi.fn().mockResolvedValue({ mounts: [] }),
mount: vi.fn().mockResolvedValue({ hostPath: "/tmp/mnt/xyz", mounted: true }),
unmount: vi.fn().mockResolvedValue({ unmounted: true }),
destroy: vi.fn().mockResolvedValue({ destroyed: true }),
snapshotEnable: vi.fn().mockResolvedValue({ enabled: true, currentSnapId: 1 }),
snapshotDisable: vi
.fn()
.mockResolvedValue({ disabled: true, purgedSnapshots: 0, purgedUndoRows: 0 }),
snapshotCommit: vi.fn(),
snapshotList: vi.fn().mockResolvedValue({ snapshots: [] }),
snapshotRestore: vi.fn(),
};
const sessions = {
create: vi.fn().mockResolvedValue({ sessionKey: "main:abc" }),
patch: vi.fn().mockResolvedValue(undefined),
load: vi.fn().mockResolvedValue({ sessionId: "abc" }),
delete: vi.fn().mockResolvedValue(undefined),
entries: vi.fn().mockResolvedValue({}),
};
const workspace = {
set: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
};
const registered = new Map<string, (params: unknown) => Promise<unknown>>();
registerGatewayMethods({
registerGatewayMethod: (m, h) => {
registered.set(m, h);
},
rpc,
sessions,
workspace,
mountRoot: "/tmp/mnt",
handleRestore: vi.fn().mockResolvedValue({
restored: true as const,
restoredMessageId: "m1",
prunedSnapshots: 0,
prunedUndoRows: 0,
}),
...extra,
});
return { rpc, sessions, workspace, registered };
}
describe("secafs.* gateway methods", () => {
it("status delegates to rpc.ping when daemon reachable", async () => {
const h = makeHarness();
const out = await h.registered.get("secafs.status")!({});
expect(out).toMatchObject({ daemonReachable: true, pgConnected: true, mountCount: 0 });
expect(h.rpc.ping).toHaveBeenCalled();
});
it("status reports unreachable when ping throws", async () => {
const h = makeHarness();
h.rpc.ping.mockRejectedValueOnce(new Error("no connection"));
const out = await h.registered.get("secafs.status")!({});
expect(out).toEqual({ daemonReachable: false, pgConnected: false, mountCount: 0 });
});
it("session.create creates session, mounts, sets workspace, patches state", async () => {
const h = makeHarness();
const out = await h.registered.get("secafs.session.create")!({});
expect(h.sessions.create).toHaveBeenCalledWith({ kind: "secafs" });
expect(h.rpc.mount).toHaveBeenCalledWith({ conversationId: "abc", hostPath: "/tmp/mnt/abc" });
expect(h.workspace.set).toHaveBeenCalledWith("main:abc", { path: "/tmp/mnt/xyz" });
expect(h.sessions.patch).toHaveBeenCalledWith("main:abc", { mountState: "mounted" });
expect(out).toMatchObject({ sessionKey: "main:abc", hostPath: "/tmp/mnt/xyz" });
});
it("session.open remounts and re-sets workspace", async () => {
const h = makeHarness();
const out = await h.registered.get("secafs.session.open")!({ sessionKey: "main:abc" });
expect(h.rpc.mount).toHaveBeenCalledWith({ conversationId: "abc", hostPath: "/tmp/mnt/abc" });
expect(h.workspace.set).toHaveBeenCalledWith("main:abc", { path: "/tmp/mnt/xyz" });
expect(out).toEqual({ hostPath: "/tmp/mnt/xyz" });
});
it("session.close unmounts, clears workspace override, and patches state but preserves data", async () => {
const h = makeHarness();
const out = await h.registered.get("secafs.session.close")!({ sessionKey: "main:abc" });
expect(h.rpc.unmount).toHaveBeenCalledWith({ conversationId: "abc" });
expect(h.workspace.clear).toHaveBeenCalledWith("main:abc");
expect(h.sessions.patch).toHaveBeenCalledWith("main:abc", { mountState: "unmounted" });
expect(h.sessions.delete).not.toHaveBeenCalled();
expect(out).toEqual({ unmounted: true });
});
it("session.destroy unmounts, destroys, clears workspace, deletes session", async () => {
const h = makeHarness();
const out = await h.registered.get("secafs.session.destroy")!({ sessionKey: "main:abc" });
expect(h.rpc.destroy).toHaveBeenCalledWith({ conversationId: "abc" });
expect(h.workspace.clear).toHaveBeenCalledWith("main:abc");
expect(h.sessions.delete).toHaveBeenCalledWith("main:abc");
expect(out).toEqual({ destroyed: true });
});
it("session.destroy deletes chat transcripts before removing store entries", async () => {
const calls: string[] = [];
const rpc = {
ping: vi.fn(),
list: vi.fn().mockResolvedValue({ mounts: [] }),
mount: vi.fn(),
unmount: vi.fn().mockResolvedValue({ unmounted: true }),
destroy: vi.fn().mockResolvedValue({ destroyed: true }),
snapshotEnable: vi.fn(),
snapshotDisable: vi.fn(),
snapshotCommit: vi.fn(),
snapshotList: vi.fn(),
snapshotRestore: vi.fn(),
};
const sessions = {
create: vi.fn(),
patch: vi.fn(),
load: vi.fn(),
delete: vi.fn().mockImplementation(async (k: string) => {
calls.push(`delete:${k}`);
}),
entries: vi.fn().mockResolvedValue({}),
};
const registered = new Map<string, (params: unknown) => Promise<unknown>>();
registerGatewayMethods({
registerGatewayMethod: (m, h) => {
registered.set(m, h);
},
rpc,
sessions,
workspace: { set: vi.fn(), clear: vi.fn() },
mountRoot: "/tmp/mnt",
handleRestore: vi.fn(),
deleteSessionArtifacts: vi.fn().mockImplementation(async (sid: string) => {
calls.push(`artifacts:${sid}`);
}),
});
await registered.get("secafs.session.destroy")!({ sessionKey: "main:abc" });
expect(calls[0]).toBe("artifacts:abc");
expect(calls.slice(1)).toContain("delete:main:abc");
});
it("session.destroy ignores unmount errors (session may already be unmounted)", async () => {
const h = makeHarness();
h.rpc.unmount.mockRejectedValueOnce(new Error("not mounted"));
const out = await h.registered.get("secafs.session.destroy")!({ sessionKey: "main:abc" });
expect(h.rpc.destroy).toHaveBeenCalledWith({ conversationId: "abc" });
expect(out).toEqual({ destroyed: true });
});
it("session.export collects workspace files + transcripts + manifest", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "secafs-export-"));
await fs.mkdir(path.join(root, "abc", "sub"), { recursive: true });
await fs.writeFile(path.join(root, "abc", "AGENTS.md"), "hello agents");
await fs.writeFile(path.join(root, "abc", "sub", "data.bin"), Buffer.from([0, 1, 2, 255]));
const h = makeHarness({
mountRoot: root,
readTranscripts: vi.fn().mockResolvedValue({
sessionJsonl: Buffer.from('{"type":"session"}\n').toString("base64"),
}),
});
h.sessions.entries.mockResolvedValue({
"main:abc": { alias: "my-sess", secafsRollback: { enabled: true } },
});
const out = (await h.registered.get("secafs.session.export")!({
sessionKey: "main:abc",
})) as SessionArchive;
expect(h.rpc.mount).toHaveBeenCalledWith({
conversationId: "abc",
hostPath: path.join(root, "abc"),
});
expect(out.manifest).toMatchObject({
schemaVersion: 1,
sid: "abc",
alias: "my-sess",
rollbackEnabled: true,
fileCount: 2,
});
const byPath = new Map(out.files.map((f) => [f.path, f.content]));
expect(Buffer.from(byPath.get("AGENTS.md")!, "base64").toString()).toBe("hello agents");
expect(Buffer.from(byPath.get("sub/data.bin")!, "base64")).toEqual(
Buffer.from([0, 1, 2, 255]),
);
expect(out.chat.sessionJsonl).toBeDefined();
await fs.rm(root, { recursive: true, force: true });
});
it("session.import creates a fresh session, writes files + transcripts, no seeding", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "secafs-import-"));
const writeTranscripts = vi.fn().mockResolvedValue(undefined);
const ensureSessionEntry = vi.fn().mockResolvedValue(undefined);
const h = makeHarness({
mountRoot: root,
writeTranscripts,
ensureSessionEntry,
defaultWorkspaceDir: "/nonexistent-should-not-be-read",
});
const archive: SessionArchive = {
manifest: {
schemaVersion: 1,
sid: "old-sid",
alias: "imported-alias",
exportedAt: "2026-06-11T00:00:00Z",
rollbackEnabled: true,
fileCount: 1,
totalBytes: 5,
},
files: [{ path: "notes/x.txt", content: Buffer.from("hello").toString("base64") }],
chat: { sessionJsonl: Buffer.from("{}\n").toString("base64") },
};
const out = (await h.registered.get("secafs.session.import")!({ archive })) as {
sessionKey: string;
filesWritten: number;
alias: string | null;
};
expect(out.sessionKey).toBe("main:abc");
expect(out.filesWritten).toBe(1);
expect(out.alias).toBe("imported-alias");
const written = await fs.readFile(path.join(root, "abc", "notes", "x.txt"), "utf8");
expect(written).toBe("hello");
expect(writeTranscripts).toHaveBeenCalledWith("abc", archive.chat);
expect(ensureSessionEntry).toHaveBeenCalled();
expect(h.sessions.patch).toHaveBeenCalledWith(
"main:abc",
expect.objectContaining({ kind: "secafs", alias: "imported-alias" }),
);
expect(h.sessions.patch).toHaveBeenCalledWith(
"agent:main:main:abc",
expect.objectContaining({ kind: "secafs", alias: "imported-alias" }),
);
await fs.rm(root, { recursive: true, force: true });
});
it("session.import rejects path escapes and malformed archives", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "secafs-import-evil-"));
const h = makeHarness({ mountRoot: root });
const evil: SessionArchive = {
manifest: {
schemaVersion: 1,
sid: "x",
alias: null,
exportedAt: "",
rollbackEnabled: false,
fileCount: 1,
totalBytes: 1,
},
files: [{ path: "../../escape.txt", content: Buffer.from("x").toString("base64") }],
chat: {},
};
await expect(h.registered.get("secafs.session.import")!({ archive: evil })).rejects.toThrow();
await expect(h.registered.get("secafs.session.import")!({ archive: { nope: 1 } })).rejects.toThrow(
/invalid archive/,
);
await fs.rm(root, { recursive: true, force: true });
});
it("rollback.snapshot delegates to snapshotNow and is absent without the hook", async () => {
const snapshotNow = vi
.fn()
.mockResolvedValue({ enabled: true, committed: true, snapId: 7, messageId: "m1" });
const h = makeHarness({ snapshotNow });
const out = await h.registered.get("secafs.rollback.snapshot")!({ sessionKey: "main:abc" });
expect(snapshotNow).toHaveBeenCalledWith({ sessionKey: "main:abc" });
expect(out).toEqual({ enabled: true, committed: true, snapId: 7, messageId: "m1" });
const bare = makeHarness();
expect(bare.registered.has("secafs.rollback.snapshot")).toBe(false);
});
it("session.create and session.open pre-seed the canonical entry BEFORE workspace.set", async () => {
const order: string[] = [];
const h = makeHarness({
ensureSessionEntry: vi.fn().mockImplementation(async (key: string) => {
order.push(`ensure:${key}`);
}),
});
h.workspace.set.mockImplementation(async (key: string) => {
order.push(`wsset:${key}`);
});
await h.registered.get("secafs.session.create")!({});
expect(order.indexOf("ensure:agent:main:main:abc")).toBeGreaterThanOrEqual(0);
expect(order.indexOf("ensure:agent:main:main:abc")).toBeLessThan(order.indexOf("wsset:main:abc"));
order.length = 0;
await h.registered.get("secafs.session.open")!({ sessionKey: "main:abc" });
expect(order.indexOf("ensure:agent:main:main:abc")).toBeGreaterThanOrEqual(0);
expect(order.indexOf("ensure:agent:main:main:abc")).toBeLessThan(order.indexOf("wsset:main:abc"));
});
it("session.create with alias patches kind+alias onto bare AND canonical keys", async () => {
const h = makeHarness();
const out = await h.registered.get("secafs.session.create")!({ alias: " my project " });
expect(out).toMatchObject({ sessionKey: "main:abc", alias: "my project" });
expect(h.sessions.patch).toHaveBeenCalledWith("main:abc", {
kind: "secafs",
alias: "my project",
});
expect(h.sessions.patch).toHaveBeenCalledWith("agent:main:main:abc", {
kind: "secafs",
alias: "my project",
});
});
it("session.list merges store entries with daemon mount state and dedupes key forms", async () => {
const h = makeHarness();
h.sessions.entries.mockResolvedValueOnce({
"main:s1": { sessionId: "s1", kind: "secafs", alias: "alpha", updatedAt: 100 },
"agent:main:main:s1": { spawnedBy: "agent:main:secafs-chat", lastInteractionAt: 300 },
"agent:main:main:s2": { spawnedWorkspaceDir: "/tmp/mnt/s2", updatedAt: 200 },
"agent:main:main:other": { sessionId: "other", updatedAt: 999 },
});
h.rpc.list.mockResolvedValueOnce({
mounts: [{ conversationId: "s1", hostPath: "/tmp/mnt/s1", since: "now" }],
});
const out = (await h.registered.get("secafs.session.list")!({})) as {
daemonReachable: boolean;
sessions: Array<Record<string, unknown>>;
};
expect(out.daemonReachable).toBe(true);
expect(out.sessions).toHaveLength(2);
expect(out.sessions[0]).toMatchObject({
sid: "s1",
sessionKey: "main:s1",
alias: "alpha",
mounted: true,
hostPath: "/tmp/mnt/s1",
});
expect(out.sessions[1]).toMatchObject({ sid: "s2", alias: null, mounted: false });
});
it("session.list reports daemonReachable=false but still lists sessions when rpc.list fails", async () => {
const h = makeHarness();
h.sessions.entries.mockResolvedValueOnce({
"main:s1": { sessionId: "s1", kind: "secafs" },
});
h.rpc.list.mockRejectedValueOnce(new Error("daemon down"));
const out = (await h.registered.get("secafs.session.list")!({})) as {
daemonReachable: boolean;
sessions: Array<{ mounted: boolean }>;
};
expect(out.daemonReachable).toBe(false);
expect(out.sessions).toHaveLength(1);
expect(out.sessions[0].mounted).toBe(false);
});
it("session.rename patches alias onto every key form; empty alias tombstones with null", async () => {
const h = makeHarness();
await h.registered.get("secafs.session.rename")!({ sessionKey: "main:abc", alias: "newname" });
expect(h.sessions.patch).toHaveBeenCalledWith("main:abc", { alias: "newname" });
expect(h.sessions.patch).toHaveBeenCalledWith("agent:main:main:abc", { alias: "newname" });
h.sessions.patch.mockClear();
await h.registered.get("secafs.session.rename")!({ sessionKey: "main:abc", alias: " " });
expect(h.sessions.patch).toHaveBeenCalledWith("main:abc", { alias: null });
});
it("session.open resolves the same conversationId for bare and canonical keys", async () => {
const h = makeHarness();
await h.registered.get("secafs.session.open")!({ sessionKey: "main:abc" });
await h.registered.get("secafs.session.open")!({ sessionKey: "agent:main:main:abc" });
const calls = h.rpc.mount.mock.calls.map((c) => c[0]);
expect(calls).toEqual([
{ conversationId: "abc", hostPath: "/tmp/mnt/abc" },
{ conversationId: "abc", hostPath: "/tmp/mnt/abc" },
]);
});
it("session.close resolves the same conversationId for bare and canonical keys", async () => {
const h = makeHarness();
await h.registered.get("secafs.session.close")!({ sessionKey: "main:abc" });
await h.registered.get("secafs.session.close")!({ sessionKey: "agent:main:main:abc" });
const calls = h.rpc.unmount.mock.calls.map((c) => c[0]);
expect(calls).toEqual([{ conversationId: "abc" }, { conversationId: "abc" }]);
});
it("session.destroy resolves the same conversationId for bare and canonical keys", async () => {
const h = makeHarness();
await h.registered.get("secafs.session.destroy")!({ sessionKey: "agent:main:main:xyz" });
expect(h.rpc.unmount).toHaveBeenCalledWith({ conversationId: "xyz" });
expect(h.rpc.destroy).toHaveBeenCalledWith({ conversationId: "xyz" });
});
it("session.destroy uses configured mainKey/defaultAgentId for non-default setups", async () => {
const rpc = {
ping: vi.fn(),
mount: vi.fn(),
unmount: vi.fn().mockResolvedValue({ unmounted: true }),
destroy: vi.fn().mockResolvedValue({ destroyed: true }),
snapshotEnable: vi.fn(),
snapshotDisable: vi.fn(),
snapshotCommit: vi.fn(),
snapshotList: vi.fn(),
snapshotRestore: vi.fn(),
};
const sessions = {
create: vi.fn(),
patch: vi.fn().mockResolvedValue(undefined),
load: vi.fn(),
delete: vi.fn().mockResolvedValue(undefined),
};
const workspace = {
set: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
};
const registered = new Map<string, (params: unknown) => Promise<unknown>>();
registerGatewayMethods({
registerGatewayMethod: (m, h) => {
registered.set(m, h);
},
rpc,
sessions,
workspace,
mountRoot: "/tmp/mnt",
mainKey: "primary",
defaultAgentId: "work",
handleRestore: vi.fn(),
});
await registered.get("secafs.session.destroy")!({ sessionKey: "agent:work:primary:abc" });
expect(workspace.clear).toHaveBeenCalledWith("primary:abc");
expect(workspace.clear).toHaveBeenCalledWith("agent:work:primary:abc");
expect(sessions.delete).toHaveBeenCalledWith("primary:abc");
expect(sessions.delete).toHaveBeenCalledWith("agent:work:primary:abc");
});
});
describe("rollback gateway methods", () => {
function makeOpts(overrides: Partial<RegisterGatewayMethodsOpts> = {}) {
const registered: Record<string, (params: unknown) => Promise<unknown>> = {};
const baseRpc = {
ping: vi.fn(async () => ({ version: "test", pgConnected: true, mountCount: 0 })),
mount: vi.fn(async () => ({ hostPath: "/m", mounted: true })),
unmount: vi.fn(async () => ({ unmounted: true })),
destroy: vi.fn(async () => ({ destroyed: true })),
snapshotEnable: vi.fn(async () => ({ enabled: true, currentSnapId: 1 })),
snapshotDisable: vi.fn(async () => ({
disabled: true,
purgedSnapshots: 4,
purgedUndoRows: 12,
})),
snapshotCommit: vi.fn(),
snapshotList: vi.fn(async () => ({
snapshots: [{ snapId: 1, label: "m1", committedAt: "t" }],
})),
snapshotRestore: vi.fn(),
};
const opts: RegisterGatewayMethodsOpts = {
registerGatewayMethod: (name, handler) => {
registered[name] = handler;
},
rpc: baseRpc,
sessions: {
create: vi.fn(),
patch: vi.fn(),
load: vi.fn(),
delete: vi.fn(),
},
workspace: { set: vi.fn(), clear: vi.fn() },
mountRoot: "/m",
handleRestore: vi.fn(async () => ({
restored: true as const,
restoredMessageId: "m1",
prunedSnapshots: 0,
prunedUndoRows: 0,
})),
...overrides,
};
registerGatewayMethods(opts);
return { registered, opts };
}
it("registers the three rollback methods", () => {
const { registered } = makeOpts();
expect(Object.keys(registered)).toEqual(
expect.arrayContaining([
"secafs.rollback.setEnabled",
"secafs.rollback.list",
"secafs.rollback.restore",
]),
);
});
it("setEnabled(true) calls snapshotEnable AND persists secafsRollback.enabled to BOTH key forms", async () => {
const { registered, opts } = makeOpts();
const r = (await registered["secafs.rollback.setEnabled"]({
sessionKey: "main:abc",
enabled: true,
})) as { enabled: boolean };
expect(opts.rpc.snapshotEnable).toHaveBeenCalledWith({ conversationId: "abc" });
expect(r.enabled).toBe(true);
expect(opts.sessions.patch).toHaveBeenCalledWith("main:abc", {
secafsRollback: expect.objectContaining({ enabled: true }),
});
expect(opts.sessions.patch).toHaveBeenCalledWith("agent:main:main:abc", {
secafsRollback: expect.objectContaining({ enabled: true }),
});
});
it("setEnabled(false) calls snapshotDisable, surfaces purge counts, and clears flag on BOTH key forms", async () => {
const { registered, opts } = makeOpts();
const r = (await registered["secafs.rollback.setEnabled"]({
sessionKey: "main:abc",
enabled: false,
})) as { enabled: boolean; purgedSnapshots: number };
expect(opts.rpc.snapshotDisable).toHaveBeenCalledWith({ conversationId: "abc" });
expect(r.enabled).toBe(false);
expect(r.purgedSnapshots).toBe(4);
expect(opts.sessions.patch).toHaveBeenCalledWith("main:abc", {
secafsRollback: { enabled: false },
});
expect(opts.sessions.patch).toHaveBeenCalledWith("agent:main:main:abc", {
secafsRollback: { enabled: false },
});
});
it("list returns snapshots with label remapped to messageId", async () => {
const { registered } = makeOpts();
const r = (await registered["secafs.rollback.list"]({ sessionKey: "main:abc" })) as {
snapshots: Array<{ messageId: string }>;
};
expect(r.snapshots).toEqual([{ snapId: 1, messageId: "m1", committedAt: "t" }]);
});
it("restore delegates to handleRestore", async () => {
const { registered, opts } = makeOpts();
const r = (await registered["secafs.rollback.restore"]({
sessionKey: "main:abc",
snapId: 5,
})) as { restored: boolean };
expect(opts.handleRestore).toHaveBeenCalledWith({ sessionKey: "main:abc", snapId: 5 });
expect(r.restored).toBe(true);
});
});