mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 19:00:21 +00:00
test: inject thread-safe gateway and ACP seams
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user