forked from bai/curriculum-project-hub
fix: confine Agent and MCP data access
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createAgentSecurityPolicy } from "../../src/agent/security.js";
|
||||
|
||||
describe("agent subprocess security policy", () => {
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("passes only provider and safe runtime variables and protects provider credentials from tools", async () => {
|
||||
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||
const policy = await createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
providerEnv: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "provider-secret",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
hostEnv: {
|
||||
PATH: "/usr/local/bin:/usr/bin:/bin",
|
||||
LANG: "C.UTF-8",
|
||||
CPH_BIN: "/usr/local/bin/cph",
|
||||
DATABASE_URL: "postgresql://platform-secret",
|
||||
FEISHU_APP_SECRET: "feishu-secret",
|
||||
HUB_SESSION_SECRET: "session-secret",
|
||||
},
|
||||
});
|
||||
const canonicalWorkspace = await realpath(workspace);
|
||||
|
||||
expect(policy.cwd).toBe(canonicalWorkspace);
|
||||
expect(policy.env).toMatchObject({
|
||||
PATH: "/usr/local/bin:/usr/bin:/bin",
|
||||
LANG: "C.UTF-8",
|
||||
CPH_BIN: "/usr/local/bin/cph",
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "provider-secret",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
});
|
||||
expect(policy.env).not.toHaveProperty("DATABASE_URL");
|
||||
expect(policy.env).not.toHaveProperty("FEISHU_APP_SECRET");
|
||||
expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET");
|
||||
expect(policy.env.HOME).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
||||
expect(policy.env.CLAUDE_CONFIG_DIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
||||
expect(policy.env.CLAUDE_CODE_TMPDIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
||||
|
||||
expect(policy.sandbox).toMatchObject({
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
allowUnsandboxedCommands: false,
|
||||
filesystem: {
|
||||
allowWrite: [canonicalWorkspace],
|
||||
denyRead: ["/"],
|
||||
allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]),
|
||||
},
|
||||
credentials: {
|
||||
envVars: expect.arrayContaining([
|
||||
{ name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" },
|
||||
{ name: "ANTHROPIC_API_KEY", mode: "deny" },
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects provider environment keys outside the explicit protocol", async () => {
|
||||
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||
|
||||
await expect(createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
providerEnv: {
|
||||
ANTHROPIC_AUTH_TOKEN: "provider-secret",
|
||||
DATABASE_URL: "must-not-cross-boundary",
|
||||
},
|
||||
hostEnv: { PATH: "/usr/bin:/bin" },
|
||||
})).rejects.toThrow("unsupported provider environment variable: DATABASE_URL");
|
||||
});
|
||||
|
||||
it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
|
||||
const { root, workspaceRoot } = await makeWorkspace();
|
||||
const outside = join(root, "outside");
|
||||
const linked = join(workspaceRoot, "org", "linked-project");
|
||||
await mkdir(outside);
|
||||
await symlink(outside, linked);
|
||||
|
||||
await expect(createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
workspaceDir: linked,
|
||||
providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" },
|
||||
hostEnv: { PATH: "/usr/bin:/bin" },
|
||||
})).rejects.toThrow("project workspace contains a symlink");
|
||||
});
|
||||
|
||||
it("rejects a project workspace symlink whose target is a sibling under the same root", async () => {
|
||||
const { workspaceRoot } = await makeWorkspace();
|
||||
const sibling = join(workspaceRoot, "org", "sibling-project");
|
||||
const linked = join(workspaceRoot, "org", "linked-project");
|
||||
await mkdir(sibling);
|
||||
await symlink(sibling, linked);
|
||||
|
||||
await expect(createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
workspaceDir: linked,
|
||||
providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" },
|
||||
hostEnv: { PATH: "/usr/bin:/bin" },
|
||||
})).rejects.toThrow("symlink");
|
||||
});
|
||||
|
||||
async function makeWorkspace(): Promise<{ root: string; workspaceRoot: string; workspace: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), "hub-agent-security-"));
|
||||
roots.push(root);
|
||||
const workspaceRoot = join(root, "workspaces");
|
||||
const workspace = join(workspaceRoot, "org", "project");
|
||||
await mkdir(workspace, { recursive: true });
|
||||
return { root, workspaceRoot, workspace };
|
||||
}
|
||||
});
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -109,6 +109,13 @@ describe("Feishu approval cards", () => {
|
||||
settings: {} as RuntimeSettings,
|
||||
logger: silentLogger(),
|
||||
authorizer: allowAllAuthorizer(),
|
||||
projectWorkspaceRoot: "/tmp",
|
||||
runAgent: async () => ({
|
||||
status: "completed",
|
||||
text: "",
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
numTurns: 0,
|
||||
}),
|
||||
});
|
||||
const result = trigger.approvalManager.register("message-1", "chat-1");
|
||||
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { access, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
downloadMessageFile,
|
||||
FeishuFileDeliveryError,
|
||||
resolveSenderName,
|
||||
sendFile,
|
||||
sendFileData,
|
||||
sendLongText,
|
||||
sendTextMessage,
|
||||
type FeishuRuntime,
|
||||
} from "../../src/feishu/client.js";
|
||||
import { SenderNameCache } from "../../src/feishu/senderCache.js";
|
||||
|
||||
const itOnLinux = process.platform === "linux" ? it : it.skip;
|
||||
|
||||
describe("Feishu client helpers", () => {
|
||||
it("downloads an image message resource through the SDK stream helper", async () => {
|
||||
itOnLinux("downloads an image message resource through the SDK stream helper", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
|
||||
try {
|
||||
const destination = join(dir, "image.png");
|
||||
const resourceWriteFile = vi.fn(async (filePath: string) => {
|
||||
await writeFile(filePath, "image bytes");
|
||||
});
|
||||
const messageResourceGet = vi.fn(async () => ({ writeFile: resourceWriteFile }));
|
||||
const workspace = join(dir, "workspace");
|
||||
await mkdir(workspace);
|
||||
const destination = join(workspace, ".cph", "inbox", "image.png");
|
||||
const getReadableStream = vi.fn(() => Readable.from([Buffer.from("image bytes")]));
|
||||
const messageResourceGet = vi.fn(async () => ({ getReadableStream }));
|
||||
const rawRequest = vi.fn(async () => Buffer.from("image bytes"));
|
||||
const rt = mockRuntime({
|
||||
fileCreate: vi.fn(),
|
||||
@@ -29,13 +34,15 @@ describe("Feishu client helpers", () => {
|
||||
request: rawRequest,
|
||||
});
|
||||
|
||||
await downloadMessageFile(rt, "message-1", "img-key-1", destination, "image");
|
||||
await expect(
|
||||
downloadMessageFile(rt, "message-1", "img-key-1", dir, workspace, ".cph/inbox/image.png", "image"),
|
||||
).resolves.toBe(join(await realpath(workspace), ".cph", "inbox", "image.png"));
|
||||
|
||||
expect(messageResourceGet).toHaveBeenCalledWith({
|
||||
params: { type: "image" },
|
||||
path: { message_id: "message-1", file_key: "img-key-1" },
|
||||
});
|
||||
expect(resourceWriteFile).toHaveBeenCalledWith(destination);
|
||||
expect(getReadableStream).toHaveBeenCalledTimes(1);
|
||||
await expect(readFile(destination, "utf8")).resolves.toBe("image bytes");
|
||||
expect(rawRequest).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
@@ -43,6 +50,27 @@ describe("Feishu client helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
itOnLinux("refuses an inbound download through a symlinked workspace directory", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
|
||||
try {
|
||||
const workspace = join(root, "workspace");
|
||||
const outside = join(root, "outside");
|
||||
await Promise.all([mkdir(workspace), mkdir(outside)]);
|
||||
await symlink(outside, join(workspace, ".cph"));
|
||||
const messageResourceGet = vi.fn(async () => ({
|
||||
getReadableStream: () => Readable.from([Buffer.from("must-not-escape")]),
|
||||
}));
|
||||
const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate: vi.fn(), messageResourceGet });
|
||||
|
||||
await expect(
|
||||
downloadMessageFile(rt, "message-1", "file-key-1", root, workspace, ".cph/inbox/file.bin", "file"),
|
||||
).rejects.toThrow(/workspace|symlink|directory/i);
|
||||
await expect(access(join(outside, "inbox", "file.bin"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts top-level file_key from the Lark SDK file upload response", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
|
||||
try {
|
||||
@@ -62,6 +90,19 @@ describe("Feishu client helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed file upload responses instead of hiding the failure", async () => {
|
||||
const rt = mockRuntime({
|
||||
fileCreate: vi.fn(async () => ({ data: {} })),
|
||||
messageCreate: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(sendFileData(rt, "chat-1", Buffer.from("bytes"), "student.pdf"))
|
||||
.rejects.toEqual(expect.objectContaining({
|
||||
name: "FeishuFileDeliveryError",
|
||||
message: "Feishu file upload response is missing file_key",
|
||||
} satisfies Partial<FeishuFileDeliveryError>));
|
||||
});
|
||||
|
||||
it("accepts top-level message_id from message create responses", async () => {
|
||||
const rt = mockRuntime({
|
||||
fileCreate: vi.fn(),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
|
||||
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import { downloadFeishuMessageResource } from "../../src/feishu/download.js";
|
||||
|
||||
const itOnLinux = process.platform === "linux" ? it : it.skip;
|
||||
|
||||
describe("Feishu message resource download", () => {
|
||||
it("exposes the download tool to default and explicitly configured roles", () => {
|
||||
expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource");
|
||||
@@ -15,24 +18,27 @@ describe("Feishu message resource download", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("downloads a bound-chat image into the project inbox", async () => {
|
||||
const workspaceDir = await mkdtemp(join(tmpdir(), "hub-feishu-download-"));
|
||||
itOnLinux("downloads a bound-chat image into the project inbox", async () => {
|
||||
const workspaceRoot = await mkdtemp(join(tmpdir(), "hub-feishu-download-"));
|
||||
const workspaceDir = join(workspaceRoot, "project");
|
||||
await mkdir(workspaceDir);
|
||||
try {
|
||||
const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } }));
|
||||
const resourceWriteFile = vi.fn(async (filePath: string) => {
|
||||
await writeFile(filePath, "image bytes");
|
||||
});
|
||||
const messageResourceGet = vi.fn(async () => ({ writeFile: resourceWriteFile }));
|
||||
const messageResourceGet = vi.fn(async () => ({
|
||||
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
|
||||
}));
|
||||
const rt = mockRuntime(messageGet, messageResourceGet);
|
||||
|
||||
const result = await downloadFeishuMessageResource(
|
||||
{ messageId: "message-1", fileKey: "img-key-1", resourceType: "image" },
|
||||
{ boundChatId: "chat-1", workspaceDir },
|
||||
{ boundChatId: "chat-1", workspaceRoot, workspaceDir },
|
||||
rt,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ resourceType: "image" });
|
||||
expect(result.path).toMatch(new RegExp(`^${escapeRegExp(join(workspaceDir, ".cph", "inbox"))}`));
|
||||
expect(result.path).toMatch(
|
||||
new RegExp(`^${escapeRegExp(join(await realpath(workspaceDir), ".cph", "inbox"))}`),
|
||||
);
|
||||
expect(result.path).toMatch(/\.png$/);
|
||||
await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes");
|
||||
expect(messageResourceGet).toHaveBeenCalledWith({
|
||||
@@ -40,7 +46,7 @@ describe("Feishu message resource download", () => {
|
||||
path: { message_id: "message-1", file_key: "img-key-1" },
|
||||
});
|
||||
} finally {
|
||||
await rm(workspaceDir, { recursive: true, force: true });
|
||||
await rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,7 +57,7 @@ describe("Feishu message resource download", () => {
|
||||
|
||||
await expect(downloadFeishuMessageResource(
|
||||
{ messageId: "message-other", fileKey: "img-key-other", resourceType: "image" },
|
||||
{ boundChatId: "chat-1", workspaceDir: "/tmp/project-1" },
|
||||
{ boundChatId: "chat-1", workspaceRoot: "/tmp", workspaceDir: "/tmp/project-1" },
|
||||
rt,
|
||||
)).rejects.toThrow("current project's bound chat");
|
||||
expect(messageResourceGet).not.toHaveBeenCalled();
|
||||
|
||||
@@ -102,6 +102,7 @@ async function triggerWithRunAgent(
|
||||
settings: mockSettings(),
|
||||
logger: rt.logger,
|
||||
authorizer: allowAllAuthorizer(),
|
||||
projectWorkspaceRoot: "/tmp",
|
||||
runAgent,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ describe("readFeishuContext", () => {
|
||||
root_id: "m-root",
|
||||
parent_id: "m-parent",
|
||||
thread_id: "thread-1",
|
||||
chat_id: "chat-1",
|
||||
msg_type: "text",
|
||||
body: { content: JSON.stringify({ text: "parent text" }) },
|
||||
},
|
||||
@@ -46,4 +47,58 @@ describe("readFeishuContext", () => {
|
||||
thread_id: "thread-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a single-message result from another chat", async () => {
|
||||
const rt = runtimeWithMessages({
|
||||
get: [{ message_id: "m-other", chat_id: "chat-other", body: { content: "secret" } }],
|
||||
list: [],
|
||||
});
|
||||
|
||||
const raw = await readFeishuContext(
|
||||
{ chat_id: "chat-1", anchor: "trigger_message", id: "m-other" },
|
||||
{ runId: "run-1", projectId: "proj-1", boundChatId: "chat-1", workspaceDir: "/tmp/proj-1" },
|
||||
rt,
|
||||
);
|
||||
|
||||
expect(JSON.parse(raw)).toMatchObject({ error: expect.stringContaining("bound chat") });
|
||||
expect(raw).not.toContain("secret");
|
||||
});
|
||||
|
||||
it("refuses an entire thread result when any item lacks the bound chat id", async () => {
|
||||
const rt = runtimeWithMessages({
|
||||
get: [],
|
||||
list: [
|
||||
{ message_id: "m-allowed", chat_id: "chat-1", body: { content: "allowed" } },
|
||||
{ message_id: "m-unscoped", body: { content: "must-not-leak" } },
|
||||
],
|
||||
});
|
||||
|
||||
const raw = await readFeishuContext(
|
||||
{ chat_id: "chat-1", anchor: "thread", id: "thread-1" },
|
||||
{ runId: "run-1", projectId: "proj-1", boundChatId: "chat-1", workspaceDir: "/tmp/proj-1" },
|
||||
rt,
|
||||
);
|
||||
|
||||
expect(JSON.parse(raw)).toMatchObject({ error: expect.stringContaining("bound chat") });
|
||||
expect(raw).not.toContain("must-not-leak");
|
||||
});
|
||||
});
|
||||
|
||||
function runtimeWithMessages(input: {
|
||||
get: Array<Record<string, unknown>>;
|
||||
list: Array<Record<string, unknown>>;
|
||||
}): FeishuRuntime {
|
||||
return {
|
||||
client: {
|
||||
im: {
|
||||
v1: {
|
||||
message: {
|
||||
get: async () => ({ data: { items: input.get } }),
|
||||
list: async () => ({ data: { items: input.list } }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: { warn() {}, info() {}, error() {}, debug() {} },
|
||||
} as unknown as FeishuRuntime;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vite
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { sendFile, withRetry, type FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import { sendFile, sendFileData, withRetry, type FeishuRuntime } from "../../src/feishu/client.js";
|
||||
|
||||
describe("withRetry", () => {
|
||||
beforeEach(() => {
|
||||
@@ -127,6 +127,25 @@ describe("sendFile retry", () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sendFileData preserves an exhausted upload failure as its cause", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cause = new Error("persistent upload failure");
|
||||
const fileCreate = vi.fn(async () => { throw cause; });
|
||||
const rt = mockRuntime({ fileCreate, messageCreate: vi.fn() });
|
||||
|
||||
const result = sendFileData(rt, "chat-1", Buffer.from("pdf bytes"), "student.pdf");
|
||||
const assertion = expect(result).rejects.toMatchObject({
|
||||
name: "FeishuFileDeliveryError",
|
||||
cause,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
await assertion;
|
||||
expect(fileCreate).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForCalls(mock: Mock, expectedCalls: number): Promise<void> {
|
||||
|
||||
@@ -1,36 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveDeliverableFile } from "../../src/feishu/fileDelivery.js";
|
||||
|
||||
const itOnLinux = process.platform === "linux" ? it : it.skip;
|
||||
|
||||
describe("file delivery path resolution", () => {
|
||||
it("resolves a repo-root file from a project workspace only when explicitly named", async () => {
|
||||
itOnLinux("never resolves a Git-root file outside the current project workspace", async () => {
|
||||
const root = await makeRepo();
|
||||
try {
|
||||
const workspace = join(root, "examples", "TH-141");
|
||||
await writeFile(join(root, "README.md"), "# root readme\n");
|
||||
|
||||
await expect(resolveDeliverableFile("README.md", workspace)).resolves.toEqual({
|
||||
path: join(root, "README.md"),
|
||||
name: "README.md",
|
||||
});
|
||||
await expect(resolveDeliverableFile("README.md", join(root, "examples"), workspace)).resolves.toBeNull();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves a bare build filename against workspace/build", async () => {
|
||||
itOnLinux("resolves a bare build filename against workspace/build", async () => {
|
||||
const root = await makeRepo();
|
||||
try {
|
||||
const workspace = join(root, "examples", "TH-141");
|
||||
const pdf = join(workspace, "build", "student.pdf");
|
||||
await writeFile(pdf, "pdf bytes");
|
||||
|
||||
await expect(resolveDeliverableFile("student.pdf", workspace)).resolves.toEqual({
|
||||
path: pdf,
|
||||
name: "student.pdf",
|
||||
});
|
||||
const resolved = await resolveDeliverableFile("student.pdf", join(root, "examples"), workspace);
|
||||
expect(resolved).toMatchObject({ path: await realpath(pdf), name: "student.pdf" });
|
||||
expect(resolved?.data.toString("utf8")).toBe("pdf bytes");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
itOnLinux("rejects a deliverable symlink even when its target exists", async () => {
|
||||
const root = await makeRepo();
|
||||
try {
|
||||
const workspace = join(root, "examples", "TH-141");
|
||||
const outside = join(root, "outside.pdf");
|
||||
await writeFile(outside, "outside secret");
|
||||
await symlink(outside, join(workspace, "build", "student.pdf"));
|
||||
|
||||
await expect(
|
||||
resolveDeliverableFile("student.pdf", join(root, "examples"), workspace),
|
||||
).rejects.toThrow(/symlink|no-follow|directory/i);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -42,7 +56,9 @@ describe("file delivery path resolution", () => {
|
||||
const workspace = join(root, "examples", "TH-141");
|
||||
await writeFile(join(workspace, "build", "student.pdf"), "pdf bytes");
|
||||
|
||||
await expect(resolveDeliverableFile("cph build 生成 PDF 给我", workspace)).resolves.toBeNull();
|
||||
await expect(
|
||||
resolveDeliverableFile("cph build 生成 PDF 给我", join(root, "examples"), workspace),
|
||||
).resolves.toBeNull();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAgent } from "../../src/agent/runner.js";
|
||||
|
||||
const queryMock = vi.hoisted(() => vi.fn());
|
||||
@@ -49,8 +52,30 @@ function abortableMessages(ac: AbortController, ...items: unknown[]) {
|
||||
}
|
||||
|
||||
describe("runAgent", () => {
|
||||
beforeEach(() => {
|
||||
let root: string;
|
||||
let workspaceRoot: string;
|
||||
let workspace: string;
|
||||
let previousSecrets: Record<string, string | undefined>;
|
||||
|
||||
beforeEach(async () => {
|
||||
queryMock.mockReset();
|
||||
root = await mkdtemp(join(tmpdir(), "hub-runner-"));
|
||||
workspaceRoot = join(root, "workspaces");
|
||||
workspace = join(workspaceRoot, "org", "project");
|
||||
await mkdir(workspace, { recursive: true });
|
||||
workspaceRoot = await realpath(workspaceRoot);
|
||||
workspace = await realpath(workspace);
|
||||
previousSecrets = Object.fromEntries(
|
||||
["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET"].map((name) => [name, process.env[name]]),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const [name, value] of Object.entries(previousSecrets)) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("passes the previous Claude SDK session id through resume", async () => {
|
||||
@@ -59,7 +84,7 @@ describe("runAgent", () => {
|
||||
const result = await runAgent({
|
||||
prompt: "再发一次",
|
||||
model: "anthropic/claude-sonnet-5",
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
|
||||
systemPrompt: undefined,
|
||||
resumeSessionId: "sdk-session-old",
|
||||
runId: "run-1",
|
||||
@@ -70,17 +95,22 @@ describe("runAgent", () => {
|
||||
expect(queryMock).toHaveBeenCalledWith({
|
||||
prompt: "再发一次",
|
||||
options: expect.objectContaining({
|
||||
cwd: "/tmp/ws",
|
||||
cwd: workspace,
|
||||
model: "anthropic/claude-sonnet-5",
|
||||
resume: "sdk-session-old",
|
||||
// ADR-0018: agent execution surface bounded by workspace.
|
||||
permissionMode: "bypassPermissions",
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: [],
|
||||
strictMcpConfig: true,
|
||||
sandbox: expect.objectContaining({
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
allowUnsandboxedCommands: false,
|
||||
filesystem: expect.objectContaining({
|
||||
allowWrite: ["/tmp/ws"],
|
||||
allowWrite: [workspace],
|
||||
denyRead: ["/"],
|
||||
allowRead: expect.arrayContaining([workspace]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -93,7 +123,7 @@ describe("runAgent", () => {
|
||||
await runAgent({
|
||||
prompt: "你好",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
|
||||
systemPrompt: undefined,
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
@@ -110,7 +140,7 @@ describe("runAgent", () => {
|
||||
await runAgent({
|
||||
prompt: "审校一下",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
|
||||
systemPrompt: undefined,
|
||||
tools: ["read_file", "cph_check", "send_file"],
|
||||
runId: "run-1",
|
||||
@@ -132,7 +162,7 @@ describe("runAgent", () => {
|
||||
await runAgent({
|
||||
prompt: "只回答",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
|
||||
systemPrompt: undefined,
|
||||
tools: [],
|
||||
runId: "run-1",
|
||||
@@ -154,7 +184,7 @@ describe("runAgent", () => {
|
||||
const result = await runAgent({
|
||||
prompt: "算一下成本",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
|
||||
systemPrompt: undefined,
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
@@ -164,20 +194,21 @@ describe("runAgent", () => {
|
||||
expect(result.costUsd).toBe(0.0042);
|
||||
});
|
||||
|
||||
it("passes provider env per run without mutating process env", async () => {
|
||||
delete process.env["CPH_TEST_PROVIDER_ENV_MUTATION"];
|
||||
it("passes only provider and safe runtime env without mutating process env", async () => {
|
||||
process.env["DATABASE_URL"] = "postgresql://platform-secret";
|
||||
process.env["FEISHU_APP_SECRET"] = "feishu-secret";
|
||||
process.env["HUB_SESSION_SECRET"] = "session-secret";
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
|
||||
|
||||
await runAgent({
|
||||
prompt: "你好",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
|
||||
systemPrompt: undefined,
|
||||
providerEnv: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
CPH_TEST_PROVIDER_ENV_MUTATION: "per-run",
|
||||
},
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
@@ -191,11 +222,22 @@ describe("runAgent", () => {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
CPH_TEST_PROVIDER_ENV_MUTATION: "per-run",
|
||||
},
|
||||
sandbox: {
|
||||
credentials: {
|
||||
envVars: expect.arrayContaining([
|
||||
{ name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" },
|
||||
{ name: "ANTHROPIC_API_KEY", mode: "deny" },
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]).toBeUndefined();
|
||||
const env = (call as { options: { env: Record<string, string | undefined> } }).options.env;
|
||||
expect(env).not.toHaveProperty("DATABASE_URL");
|
||||
expect(env).not.toHaveProperty("FEISHU_APP_SECRET");
|
||||
expect(env).not.toHaveProperty("HUB_SESSION_SECRET");
|
||||
expect(process.env["DATABASE_URL"]).toBe("postgresql://platform-secret");
|
||||
});
|
||||
|
||||
it("persists structured user and assistant messages best-effort", async () => {
|
||||
@@ -209,7 +251,7 @@ describe("runAgent", () => {
|
||||
await runAgent({
|
||||
prompt: "你好",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
|
||||
systemPrompt: undefined,
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
@@ -241,7 +283,7 @@ describe("runAgent", () => {
|
||||
const runPromise = runAgent({
|
||||
prompt: "长任务",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
|
||||
systemPrompt: undefined,
|
||||
runId: "run-abort",
|
||||
sessionId: "hub-session-abort",
|
||||
|
||||
Reference in New Issue
Block a user