test: inject thread-safe gateway and ACP seams

This commit is contained in:
Peter Steinberger
2026-03-23 04:34:42 -07:00
parent d841d02439
commit 6bcd9a801a
10 changed files with 330 additions and 101 deletions

View File

@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
pruneAgentConfig: vi.fn(() => ({ config: {}, removedBindings: 0 })),
writeConfigFile: vi.fn(async () => {}),
ensureAgentWorkspace: vi.fn(async () => {}),
isWorkspaceSetupCompleted: vi.fn(async () => false),
resolveAgentDir: vi.fn(() => "/agents/test-agent"),
resolveAgentWorkspaceDir: vi.fn(() => "/workspace/test-agent"),
resolveSessionTranscriptsDirForAgent: vi.fn(() => "/transcripts/test-agent"),
@@ -30,6 +31,7 @@ const mocks = vi.hoisted(() => ({
fsStat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
fsRealpath: vi.fn(async (p: string) => p),
fsReadlink: vi.fn(async () => ""),
fsOpen: vi.fn(async () => ({}) as unknown),
writeFileWithinRoot: vi.fn(async () => {}),
}));
@@ -59,6 +61,7 @@ vi.mock("../../agents/workspace.js", async () => {
return {
...actual,
ensureAgentWorkspace: mocks.ensureAgentWorkspace,
isWorkspaceSetupCompleted: mocks.isWorkspaceSetupCompleted,
};
});
@@ -101,6 +104,7 @@ vi.mock("node:fs/promises", async () => {
stat: mocks.fsStat,
lstat: mocks.fsLstat,
realpath: mocks.fsRealpath,
readlink: mocks.fsReadlink,
open: mocks.fsOpen,
};
return { ...patched, default: patched };
@@ -110,12 +114,16 @@ vi.mock("node:fs/promises", async () => {
/* Import after mocks are set up */
/* ------------------------------------------------------------------ */
const { agentsHandlers } = await import("./agents.js");
const { __testing: agentsTesting, agentsHandlers } = await import("./agents.js");
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
beforeEach(() => {
agentsTesting.resetDepsForTests();
});
function makeCall(method: keyof typeof agentsHandlers, params: Record<string, unknown>) {
const respond = vi.fn();
const handler = agentsHandlers[method];
@@ -160,36 +168,32 @@ function makeFileStat(params?: {
} as unknown as import("node:fs").Stats;
}
function makeSymlinkStat(params?: { dev?: number; ino?: number }): import("node:fs").Stats {
return {
isFile: () => false,
isSymbolicLink: () => true,
size: 0,
mtimeMs: 0,
dev: params?.dev ?? 1,
ino: params?.ino ?? 2,
} as unknown as import("node:fs").Stats;
}
function mockWorkspaceStateRead(params: {
setupCompletedAt?: string;
errorCode?: string;
rawContent?: string;
}) {
mocks.fsReadFile.mockImplementation(async (...args: unknown[]) => {
const filePath = args[0];
if (String(filePath).endsWith("workspace-state.json")) {
agentsTesting.setDepsForTests({
isWorkspaceSetupCompleted: async () => {
if (params.errorCode) {
throw createErrnoError(params.errorCode);
}
if (typeof params.rawContent === "string") {
return params.rawContent;
throw new SyntaxError("Expected property name or '}' in JSON");
}
return JSON.stringify({
setupCompletedAt: params.setupCompletedAt ?? "2026-02-15T14:00:00.000Z",
});
return (
typeof params.setupCompletedAt === "string" && params.setupCompletedAt.trim().length > 0
);
},
});
mocks.isWorkspaceSetupCompleted.mockImplementation(async () => {
if (params.errorCode) {
throw createErrnoError(params.errorCode);
}
throw createEnoentError();
if (typeof params.rawContent === "string") {
throw new SyntaxError("Expected property name or '}' in JSON");
}
return typeof params.setupCompletedAt === "string" && params.setupCompletedAt.trim().length > 0;
});
}
@@ -505,6 +509,8 @@ describe("agents.files.list", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.loadConfigReturn = {};
mocks.isWorkspaceSetupCompleted.mockReset().mockResolvedValue(false);
mocks.fsReadlink.mockReset().mockResolvedValue("");
});
it("includes BOOTSTRAP.md when setup has not completed", async () => {
@@ -543,22 +549,12 @@ describe("agents.files.get/set symlink safety", () => {
function mockWorkspaceEscapeSymlink() {
const workspace = "/workspace/test-agent";
const candidate = path.resolve(workspace, "AGENTS.md");
mocks.fsRealpath.mockImplementation(async (p: string) => {
if (p === workspace) {
return workspace;
}
if (p === candidate) {
return "/outside/secret.txt";
}
return p;
});
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
const p = typeof args[0] === "string" ? args[0] : "";
if (p === candidate) {
return makeSymlinkStat();
}
throw createEnoentError();
agentsTesting.setDepsForTests({
resolveAgentWorkspaceFilePath: async ({ name }) => ({
kind: "invalid",
requestPath: path.join(workspace, name),
reason: "path escapes workspace root",
}),
});
}
@@ -578,24 +574,24 @@ describe("agents.files.get/set symlink safety", () => {
it("allows in-workspace symlink reads and writes through symlink aliases", async () => {
const workspace = "/workspace/test-agent";
const candidate = path.resolve(workspace, "AGENTS.md");
const target = path.resolve(workspace, "policies", "AGENTS.md");
const targetStat = makeFileStat({ size: 7, mtimeMs: 1700, dev: 9, ino: 42 });
mocks.fsRealpath.mockImplementation(async (p: string) => {
if (p === workspace) {
return workspace;
}
if (p === candidate) {
return target;
}
return p;
agentsTesting.setDepsForTests({
readLocalFileSafely: async () => ({
buffer: Buffer.from("inside\n"),
realPath: target,
stat: targetStat,
}),
resolveAgentWorkspaceFilePath: async ({ name }) => ({
kind: "ready",
requestPath: path.join(workspace, name),
ioPath: target,
workspaceReal: workspace,
}),
});
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
const p = typeof args[0] === "string" ? args[0] : "";
if (p === candidate) {
return makeSymlinkStat({ dev: 9, ino: 41 });
}
if (p === target) {
return targetStat;
}
@@ -608,16 +604,6 @@ describe("agents.files.get/set symlink safety", () => {
}
throw createEnoentError();
});
mocks.fsOpen.mockImplementation(
async () =>
({
stat: async () => targetStat,
readFile: async () => Buffer.from("inside\n"),
truncate: async () => {},
writeFile: async () => {},
close: async () => {},
}) as unknown,
);
const getCall = makeCall("agents.files.get", { agentId: "main", name: "AGENTS.md" });
await getCall.promise;

View File

@@ -61,6 +61,32 @@ const BOOTSTRAP_FILE_NAMES_POST_ONBOARDING = BOOTSTRAP_FILE_NAMES.filter(
(name) => name !== DEFAULT_BOOTSTRAP_FILENAME,
);
const agentsHandlerDeps = {
isWorkspaceSetupCompleted,
readLocalFileSafely,
resolveAgentWorkspaceFilePath,
writeFileWithinRoot,
};
export const __testing = {
setDepsForTests(
overrides: Partial<{
isWorkspaceSetupCompleted: typeof isWorkspaceSetupCompleted;
readLocalFileSafely: typeof readLocalFileSafely;
resolveAgentWorkspaceFilePath: typeof resolveAgentWorkspaceFilePath;
writeFileWithinRoot: typeof writeFileWithinRoot;
}>,
) {
Object.assign(agentsHandlerDeps, overrides);
},
resetDepsForTests() {
agentsHandlerDeps.isWorkspaceSetupCompleted = isWorkspaceSetupCompleted;
agentsHandlerDeps.readLocalFileSafely = readLocalFileSafely;
agentsHandlerDeps.resolveAgentWorkspaceFilePath = resolveAgentWorkspaceFilePath;
agentsHandlerDeps.writeFileWithinRoot = writeFileWithinRoot;
},
};
const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const;
const ALLOWED_FILE_NAMES = new Set<string>([...BOOTSTRAP_FILE_NAMES, ...MEMORY_FILE_NAMES]);
@@ -417,7 +443,7 @@ async function resolveWorkspaceFilePathOrRespond(params: {
workspaceDir: string;
name: string;
}): Promise<ResolvedWorkspaceFilePath | undefined> {
const resolvedPath = await resolveAgentWorkspaceFilePath({
const resolvedPath = await agentsHandlerDeps.resolveAgentWorkspaceFilePath({
workspaceDir: params.workspaceDir,
name: params.name,
allowMissing: true,
@@ -653,7 +679,7 @@ export const agentsHandlers: GatewayRequestHandlers = {
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
let hideBootstrap = false;
try {
hideBootstrap = await isWorkspaceSetupCompleted(workspaceDir);
hideBootstrap = await agentsHandlerDeps.isWorkspaceSetupCompleted(workspaceDir);
} catch {
// Fall back to showing BOOTSTRAP if workspace state cannot be read.
}
@@ -685,7 +711,7 @@ export const agentsHandlers: GatewayRequestHandlers = {
}
let safeRead: Awaited<ReturnType<typeof readLocalFileSafely>>;
try {
safeRead = await readLocalFileSafely({ filePath: resolvedPath.ioPath });
safeRead = await agentsHandlerDeps.readLocalFileSafely({ filePath: resolvedPath.ioPath });
} catch (err) {
if (err instanceof SafeOpenError && err.code === "not-found") {
respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath });
@@ -742,7 +768,7 @@ export const agentsHandlers: GatewayRequestHandlers = {
return;
}
try {
await writeFileWithinRoot({
await agentsHandlerDeps.writeFileWithinRoot({
rootDir: resolvedPath.workspaceReal,
relativePath: relativeWritePath,
data: content,