- commit
- b1443f4
- parent
- e6a758f
- author
- im_wower
- date
- 2026-03-30 00:05:18 +0800 CST
Merge branch 'feat/code-file-serving'
4 files changed,
+435,
-14
+135,
-1
1@@ -1,7 +1,7 @@
2 import assert from "node:assert/strict";
3 import { EventEmitter } from "node:events";
4 import { createServer } from "node:http";
5-import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7 import { createConnection } from "node:net";
8 import { homedir, tmpdir } from "node:os";
9 import { join } from "node:path";
10@@ -2506,6 +2506,7 @@ test("parseConductorCliRequest merges launchd env defaults with CLI overrides",
11 BAA_CONDUCTOR_HOST: "mini",
12 BAA_CONDUCTOR_ROLE: "primary",
13 BAA_CONDUCTOR_PUBLIC_API_BASE: "https://public.example.test/",
14+ BAA_CODE_ROOT_DIR: "/tmp/code-root/",
15 BAA_CODEXD_LOCAL_API_BASE: "http://127.0.0.1:4323/",
16 BAA_CONDUCTOR_LOCAL_API: "http://127.0.0.1:4317/",
17 BAA_SHARED_TOKEN: "replace-me",
18@@ -2523,6 +2524,7 @@ test("parseConductorCliRequest merges launchd env defaults with CLI overrides",
19 assert.equal(request.config.nodeId, "mini-main");
20 assert.equal(request.config.publicApiBase, "https://public.example.test");
21 assert.equal(request.config.controlApiBase, "https://public.example.test");
22+ assert.equal(request.config.codeRootDir, "/tmp/code-root");
23 assert.equal(request.config.codexdLocalApiBase, "http://127.0.0.1:4323");
24 assert.equal(request.config.localApiBase, "http://127.0.0.1:4317");
25 assert.equal(request.config.paths.runsDir, "/tmp/runs");
26@@ -4364,6 +4366,138 @@ test("handleConductorHttpRequest serves artifact files and robots.txt", async ()
27 });
28 });
29
30+test("handleConductorHttpRequest serves code files and blocks unsafe paths", async () => {
31+ const { repository, snapshot } = await createLocalApiFixture();
32+ const codeRootDir = mkdtempSync(join(tmpdir(), "baa-conductor-code-route-"));
33+ const repoDir = join(codeRootDir, "demo-repo");
34+
35+ try {
36+ mkdirSync(join(repoDir, "src"), { recursive: true });
37+ mkdirSync(join(repoDir, ".git", "objects"), { recursive: true });
38+ writeFileSync(join(repoDir, "package.json"), "{\n \"name\": \"demo-repo\"\n}\n");
39+ writeFileSync(join(repoDir, ".env"), "SECRET=1\n");
40+ writeFileSync(join(repoDir, ".credentials"), "token=secret\n");
41+ writeFileSync(join(repoDir, ".git", "objects", "secret"), "hidden\n");
42+ writeFileSync(join(repoDir, "image.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
43+ writeFileSync(join(repoDir, "src", "index.ts"), "export const demo = true;\n");
44+
45+ const context = {
46+ codeRootDir,
47+ repository,
48+ snapshotLoader: () => snapshot
49+ };
50+
51+ const fileResponse = await handleConductorHttpRequest(
52+ {
53+ method: "GET",
54+ path: "/code/demo-repo/package.json"
55+ },
56+ context
57+ );
58+ assert.equal(fileResponse.status, 200);
59+ assert.equal(fileResponse.headers["content-type"], "text/plain; charset=utf-8");
60+ assert.equal(Buffer.from(fileResponse.body).toString("utf8"), "{\n \"name\": \"demo-repo\"\n}\n");
61+
62+ const directoryResponse = await handleConductorHttpRequest(
63+ {
64+ method: "GET",
65+ path: "/code/demo-repo"
66+ },
67+ context
68+ );
69+ assert.equal(directoryResponse.status, 200);
70+ assert.deepEqual(Buffer.from(directoryResponse.body).toString("utf8").split("\n"), [
71+ "package.json",
72+ "src"
73+ ]);
74+
75+ const hiddenEnvResponse = await handleConductorHttpRequest(
76+ {
77+ method: "GET",
78+ path: "/code/demo-repo/.env"
79+ },
80+ context
81+ );
82+ assert.equal(hiddenEnvResponse.status, 403);
83+ assert.equal(parseJsonBody(hiddenEnvResponse).error, "forbidden");
84+
85+ const hiddenCredentialsResponse = await handleConductorHttpRequest(
86+ {
87+ method: "GET",
88+ path: "/code/demo-repo/.credentials"
89+ },
90+ context
91+ );
92+ assert.equal(hiddenCredentialsResponse.status, 403);
93+ assert.equal(parseJsonBody(hiddenCredentialsResponse).error, "forbidden");
94+
95+ const hiddenGitObjectsResponse = await handleConductorHttpRequest(
96+ {
97+ method: "GET",
98+ path: "/code/demo-repo/.git/objects/secret"
99+ },
100+ context
101+ );
102+ assert.equal(hiddenGitObjectsResponse.status, 403);
103+ assert.equal(parseJsonBody(hiddenGitObjectsResponse).error, "forbidden");
104+
105+ const hiddenGitConfigResponse = await handleConductorHttpRequest(
106+ {
107+ method: "GET",
108+ path: "/code/demo-repo/.git/config"
109+ },
110+ context
111+ );
112+ assert.equal(hiddenGitConfigResponse.status, 403);
113+ assert.equal(parseJsonBody(hiddenGitConfigResponse).error, "forbidden");
114+
115+ const hiddenGitDirectoryResponse = await handleConductorHttpRequest(
116+ {
117+ method: "GET",
118+ path: "/code/demo-repo/.git"
119+ },
120+ context
121+ );
122+ assert.equal(hiddenGitDirectoryResponse.status, 403);
123+ assert.equal(parseJsonBody(hiddenGitDirectoryResponse).error, "forbidden");
124+
125+ const traversalResponse = await handleConductorHttpRequest(
126+ {
127+ method: "GET",
128+ path: "/code/demo-repo/%2e%2e/%2e%2e/%2e%2e/etc/passwd"
129+ },
130+ context
131+ );
132+ assert.ok([403, 404].includes(traversalResponse.status));
133+ assert.match(parseJsonBody(traversalResponse).error, /^(forbidden|not_found)$/u);
134+
135+ const binaryResponse = await handleConductorHttpRequest(
136+ {
137+ method: "GET",
138+ path: "/code/demo-repo/image.png"
139+ },
140+ context
141+ );
142+ assert.equal(binaryResponse.status, 403);
143+ assert.equal(parseJsonBody(binaryResponse).error, "forbidden");
144+
145+ const missingResponse = await handleConductorHttpRequest(
146+ {
147+ method: "GET",
148+ path: "/code/demo-repo/missing.ts"
149+ },
150+ context
151+ );
152+ assert.equal(missingResponse.status, 404);
153+ assert.equal(parseJsonBody(missingResponse).error, "not_found");
154+ } finally {
155+ rmSync(codeRootDir, {
156+ force: true,
157+ recursive: true
158+ });
159+ }
160+});
161+
162 test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status surfaces", async () => {
163 await withRuntimeFixture(async ({ runtime }) => {
164 assert.equal(runtime.getRuntimeSnapshot().runtime.started, false);
+17,
-1
1@@ -6,7 +6,7 @@ import {
2 type ServerResponse
3 } from "node:http";
4 import type { AddressInfo } from "node:net";
5-import { join } from "node:path";
6+import { join, resolve } from "node:path";
7 import {
8 ARTIFACTS_DIRNAME,
9 ARTIFACT_DB_FILENAME,
10@@ -84,6 +84,7 @@ const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
11 const DEFAULT_LEASE_RENEW_INTERVAL_MS = 5_000;
12 const DEFAULT_LEASE_TTL_SEC = 30;
13 const DEFAULT_RENEW_FAILURE_THRESHOLD = 2;
14+const DEFAULT_CODE_ROOT_DIR = "/Users/george/code/";
15
16 const STARTUP_CHECKLIST: StartupChecklistItem[] = [
17 { key: "register-controller", description: "注册 controller 并写入初始 heartbeat" },
18@@ -164,6 +165,7 @@ export interface ConductorRuntimeConfig extends ConductorConfig {
19 artifactInlineThreshold?: number | null;
20 artifactSummaryLength?: number | null;
21 claudeCodedLocalApiBase?: string | null;
22+ codeRootDir?: string | null;
23 codexdLocalApiBase?: string | null;
24 localApiAllowedHosts?: readonly string[] | string | null;
25 localApiBase?: string | null;
26@@ -176,6 +178,7 @@ export interface ResolvedConductorRuntimeConfig
27 artifactInlineThreshold: number;
28 artifactSummaryLength: number;
29 claudeCodedLocalApiBase: string | null;
30+ codeRootDir: string;
31 controlApiBase: string;
32 heartbeatIntervalMs: number;
33 leaseRenewIntervalMs: number;
34@@ -722,6 +725,7 @@ class ConductorLocalHttpServer {
35 private readonly artifactStore: ArtifactStore;
36 private readonly browserRequestPolicy: BrowserRequestPolicyController;
37 private readonly claudeCodedLocalApiBase: string | null;
38+ private readonly codeRootDir: string;
39 private readonly codexdLocalApiBase: string | null;
40 private readonly fetchImpl: typeof fetch;
41 private readonly firefoxWebSocketServer: ConductorFirefoxWebSocketServer;
42@@ -740,6 +744,7 @@ class ConductorLocalHttpServer {
43 repository: ControlPlaneRepository,
44 artifactStore: ArtifactStore,
45 snapshotLoader: () => ConductorRuntimeSnapshot,
46+ codeRootDir: string,
47 codexdLocalApiBase: string | null,
48 claudeCodedLocalApiBase: string | null,
49 fetchImpl: typeof fetch,
50@@ -755,6 +760,7 @@ class ConductorLocalHttpServer {
51 this.artifactStore = artifactStore;
52 this.browserRequestPolicy = new BrowserRequestPolicyController(browserRequestPolicyOptions);
53 this.claudeCodedLocalApiBase = claudeCodedLocalApiBase;
54+ this.codeRootDir = codeRootDir;
55 this.codexdLocalApiBase = codexdLocalApiBase;
56 this.fetchImpl = fetchImpl;
57 this.localApiBase = localApiBase;
58@@ -767,6 +773,7 @@ class ConductorLocalHttpServer {
59 const nowMs = () => this.now() * 1000;
60 const localApiContext = {
61 artifactStore: this.artifactStore,
62+ codeRootDir: this.codeRootDir,
63 fetchImpl: this.fetchImpl,
64 now: this.now,
65 repository: this.repository,
66@@ -1651,6 +1658,10 @@ function resolveSourcePublicApiBase(
67 );
68 }
69
70+function resolveCodeRootDir(value: string | null | undefined): string {
71+ return resolve(normalizeOptionalString(value) ?? DEFAULT_CODE_ROOT_DIR);
72+}
73+
74 type CanonicalConductorConfig = Omit<ConductorConfig, "controlApiBase" | "publicApiBase"> & {
75 controlApiBase: string;
76 publicApiBase: string;
77@@ -1717,6 +1728,7 @@ export function resolveConductorRuntimeConfig(
78 role: parseConductorRole("Conductor role", config.role),
79 controlApiBase: normalizeBaseUrl(publicApiBase),
80 claudeCodedLocalApiBase: resolveLocalApiBase(config.claudeCodedLocalApiBase),
81+ codeRootDir: resolveCodeRootDir(config.codeRootDir),
82 codexdLocalApiBase: resolveLocalApiBase(config.codexdLocalApiBase),
83 heartbeatIntervalMs,
84 leaseRenewIntervalMs,
85@@ -1814,6 +1826,7 @@ function resolveRuntimeConfigFromSources(
86 claudeCodedLocalApiBase: normalizeOptionalString(
87 overrides.claudeCodedLocalApiBase ?? env.BAA_CLAUDE_CODED_LOCAL_API_BASE
88 ),
89+ codeRootDir: normalizeOptionalString(env.BAA_CODE_ROOT_DIR),
90 codexdLocalApiBase: normalizeOptionalString(
91 overrides.codexdLocalApiBase ?? env.BAA_CODEXD_LOCAL_API_BASE
92 ),
93@@ -2049,6 +2062,7 @@ function formatConfigText(config: ResolvedConductorRuntimeConfig): string {
94 `public_api_base: ${config.publicApiBase}`,
95 `claude_coded_local_api_base: ${config.claudeCodedLocalApiBase ?? "not-configured"}`,
96 `codexd_local_api_base: ${config.codexdLocalApiBase ?? "not-configured"}`,
97+ `code_root_dir: ${config.codeRootDir}`,
98 `local_api_base: ${config.localApiBase ?? "not-configured"}`,
99 `firefox_ws_url: ${buildFirefoxWebSocketUrl(config.localApiBase) ?? "not-configured"}`,
100 `local_api_allowed_hosts: ${config.localApiAllowedHosts.join(",") || "loopback-only"}`,
101@@ -2123,6 +2137,7 @@ function getUsageText(): string {
102 " BAA_CONDUCTOR_ROLE",
103 " BAA_CONDUCTOR_PUBLIC_API_BASE",
104 " BAA_CONTROL_API_BASE (legacy env alias for conductor upstream/public API base)",
105+ " BAA_CODE_ROOT_DIR",
106 " BAA_CODEXD_LOCAL_API_BASE",
107 " BAA_CONDUCTOR_LOCAL_API",
108 " BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS",
109@@ -2194,6 +2209,7 @@ export class ConductorRuntime {
110 this.localControlPlane.repository,
111 this.artifactStore,
112 () => this.getRuntimeSnapshot(),
113+ this.config.codeRootDir,
114 this.config.codexdLocalApiBase,
115 this.config.claudeCodedLocalApiBase,
116 options.fetchImpl ?? globalThis.fetch,
+270,
-5
1@@ -1,6 +1,6 @@
2-import { readFileSync } from "node:fs";
3+import * as nodeFs from "node:fs";
4 import { randomUUID } from "node:crypto";
5-import { join } from "node:path";
6+import * as nodePath from "node:path";
7 import {
8 buildArtifactRelativePath,
9 buildArtifactPublicUrl,
10@@ -71,6 +71,18 @@ import {
11 } from "./browser-request-policy.js";
12 import type { BaaBrowserDeliveryBridge } from "./artifacts/upload-session.js";
13
14+interface FileStatsLike {
15+ isDirectory(): boolean;
16+}
17+
18+const { readFileSync } = nodeFs;
19+const { join, resolve } = nodePath;
20+const readdirSync = (nodeFs as unknown as { readdirSync(path: string): string[] }).readdirSync;
21+const realpathSync = (nodeFs as unknown as { realpathSync(path: string): string }).realpathSync;
22+const statSync = (nodeFs as unknown as { statSync(path: string): FileStatsLike }).statSync;
23+const extname = (nodePath as unknown as { extname(path: string): string }).extname;
24+const relative = (nodePath as unknown as { relative(from: string, to: string): string }).relative;
25+
26 const DEFAULT_LIST_LIMIT = 20;
27 const DEFAULT_LOG_LIMIT = 200;
28 const MAX_LIST_LIMIT = 100;
29@@ -95,6 +107,8 @@ const CLAUDE_CODED_ROUTE_IDS = new Set([
30 const HOST_OPERATIONS_ROUTE_IDS = new Set(["host.exec", "host.files.read", "host.files.write"]);
31 const HOST_OPERATIONS_AUTH_HEADER = "Authorization: Bearer <BAA_SHARED_TOKEN>";
32 const HOST_OPERATIONS_WWW_AUTHENTICATE = 'Bearer realm="baa-conductor-host-ops"';
33+const DEFAULT_CODE_ROOT_DIR = "/Users/george/code/";
34+const CODE_ROUTE_CONTENT_TYPE = "text/plain; charset=utf-8";
35 const STATUS_VIEW_HTML_HEADERS = {
36 "cache-control": "no-store",
37 "content-type": "text/html; charset=utf-8"
38@@ -105,6 +119,45 @@ const SSE_RESPONSE_HEADERS = {
39 "content-type": "text/event-stream; charset=utf-8"
40 } as const;
41 const ALLOWED_ARTIFACT_SCOPES = new Set(["exec", "msg", "session"]);
42+const BLOCKED_CODE_FILE_NAMES = new Set([".credentials", ".env"]);
43+const BLOCKED_CODE_PATH_SEGMENTS = new Set([".git"]);
44+const BLOCKED_CODE_BINARY_EXTENSIONS = new Set([
45+ ".7z",
46+ ".a",
47+ ".avi",
48+ ".class",
49+ ".db",
50+ ".dll",
51+ ".dylib",
52+ ".eot",
53+ ".exe",
54+ ".gif",
55+ ".gz",
56+ ".ico",
57+ ".jar",
58+ ".jpeg",
59+ ".jpg",
60+ ".mkv",
61+ ".mov",
62+ ".mp3",
63+ ".mp4",
64+ ".o",
65+ ".otf",
66+ ".pdf",
67+ ".png",
68+ ".pyc",
69+ ".rar",
70+ ".so",
71+ ".sqlite",
72+ ".tar",
73+ ".tgz",
74+ ".ttf",
75+ ".war",
76+ ".webp",
77+ ".woff",
78+ ".woff2",
79+ ".zip"
80+]);
81 const ARTIFACT_FILE_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
82 const RECENT_SESSIONS_TXT_ARTIFACT_PATH = buildArtifactRelativePath("session", "latest.txt");
83 const RECENT_SESSIONS_JSON_ARTIFACT_PATH = buildArtifactRelativePath("session", "latest.json");
84@@ -210,6 +263,7 @@ type UpstreamErrorEnvelope = JsonObject & {
85 interface LocalApiRequestContext {
86 artifactStore: ArtifactStore | null;
87 claudeCodedLocalApiBase: string | null;
88+ codeRootDir: string;
89 deliveryBridge: BaaBrowserDeliveryBridge | null;
90 browserBridge: BrowserBridgeController | null;
91 browserRequestPolicy: BrowserRequestPolicyController | null;
92@@ -263,6 +317,7 @@ export interface ConductorRuntimeApiSnapshot {
93 export interface ConductorLocalApiContext {
94 artifactStore?: ArtifactStore | null;
95 claudeCodedLocalApiBase?: string | null;
96+ codeRootDir?: string | null;
97 deliveryBridge?: BaaBrowserDeliveryBridge | null;
98 browserBridge?: BrowserBridgeController | null;
99 browserRequestPolicy?: BrowserRequestPolicyController | null;
100@@ -366,6 +421,13 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
101 pathPattern: "/artifact/repo/:repo_name/*",
102 summary: "读取 stagit 生成的 git 仓库静态页面"
103 },
104+ {
105+ id: "service.code.read",
106+ kind: "read",
107+ method: "GET",
108+ pathPattern: "/code/:code_path*",
109+ summary: "读取代码根目录下的文本源码文件,目录请求返回纯文本列表"
110+ },
111 {
112 id: "service.health",
113 kind: "read",
114@@ -5809,6 +5871,192 @@ async function handleArtifactRepoRead(context: LocalApiRequestContext): Promise<
115 }
116 }
117
118+function buildPlainTextBinaryResponse(status: number, body: string): ConductorHttpResponse {
119+ return binaryResponse(status, Buffer.from(body, "utf8"), {
120+ "content-type": CODE_ROUTE_CONTENT_TYPE
121+ });
122+}
123+
124+function resolveCodeRootDir(value: string | null | undefined): string {
125+ return resolve(normalizeOptionalString(value) ?? DEFAULT_CODE_ROOT_DIR);
126+}
127+
128+function normalizeCodeRelativePath(value: string): string {
129+ return value.split(/[\\/]+/u).filter(Boolean).join("/");
130+}
131+
132+function isBlockedCodePath(relativePath: string): boolean {
133+ const normalized = normalizeCodeRelativePath(relativePath);
134+ const segments = normalized === "" ? [] : normalized.split("/");
135+
136+ if (segments.some((segment) => BLOCKED_CODE_FILE_NAMES.has(segment) || BLOCKED_CODE_PATH_SEGMENTS.has(segment))) {
137+ return true;
138+ }
139+
140+ return false;
141+}
142+
143+function isBinaryCodePath(relativePath: string): boolean {
144+ const normalized = normalizeCodeRelativePath(relativePath);
145+ return normalized !== "" && BLOCKED_CODE_BINARY_EXTENSIONS.has(extname(normalized).toLowerCase());
146+}
147+
148+function isPathOutsideRoot(rootPath: string, targetPath: string): boolean {
149+ const relativePath = relative(rootPath, targetPath);
150+
151+ if (relativePath === "") {
152+ return false;
153+ }
154+
155+ const [firstSegment = ""] = relativePath.split(/[\\/]+/u).filter(Boolean);
156+ return firstSegment === "..";
157+}
158+
159+function buildCodeAccessForbiddenError(): LocalApiHttpError {
160+ return new LocalApiHttpError(403, "forbidden", "Requested code path is not allowed.");
161+}
162+
163+function assertAllowedRequestedCodePath(relativePath: string): void {
164+ if (relativePath.includes("\u0000") || relativePath.includes("\\") || relativePath.startsWith("/")) {
165+ throw buildCodeAccessForbiddenError();
166+ }
167+
168+ const segments = relativePath === "" ? [] : relativePath.split("/");
169+
170+ if (segments.some((segment) => segment === "" || segment === "..")) {
171+ throw buildCodeAccessForbiddenError();
172+ }
173+
174+ if (isBlockedCodePath(relativePath)) {
175+ throw buildCodeAccessForbiddenError();
176+ }
177+}
178+
179+function getCodeRootRealPath(codeRootDir: string): string {
180+ try {
181+ return realpathSync(codeRootDir);
182+ } catch (error) {
183+ if (isMissingFileError(error)) {
184+ throw new LocalApiHttpError(
185+ 500,
186+ "code_root_not_found",
187+ `Configured code root "${codeRootDir}" does not exist.`
188+ );
189+ }
190+
191+ throw error;
192+ }
193+}
194+
195+function resolveCodeAccessTarget(codeRootDir: string, requestedPath: string): {
196+ codeRootRealPath: string;
197+ resolvedPath: string;
198+ resolvedRelativePath: string;
199+ stats: ReturnType<typeof statSync>;
200+} {
201+ assertAllowedRequestedCodePath(requestedPath);
202+ const codeRootRealPath = getCodeRootRealPath(codeRootDir);
203+ const resolvedPath = realpathSync(resolve(codeRootRealPath, requestedPath === "" ? "." : requestedPath));
204+
205+ if (isPathOutsideRoot(codeRootRealPath, resolvedPath)) {
206+ throw buildCodeAccessForbiddenError();
207+ }
208+
209+ const resolvedRelativePath = normalizeCodeRelativePath(relative(codeRootRealPath, resolvedPath));
210+
211+ if (isBlockedCodePath(resolvedRelativePath)) {
212+ throw buildCodeAccessForbiddenError();
213+ }
214+
215+ return {
216+ codeRootRealPath,
217+ resolvedPath,
218+ resolvedRelativePath,
219+ stats: statSync(resolvedPath)
220+ };
221+}
222+
223+function isVisibleCodeDirectoryEntry(
224+ codeRootRealPath: string,
225+ directoryPath: string,
226+ entryName: string
227+): boolean {
228+ const visibleRelativePath = normalizeCodeRelativePath(relative(codeRootRealPath, resolve(directoryPath, entryName)));
229+
230+ if (isBlockedCodePath(visibleRelativePath)) {
231+ return false;
232+ }
233+
234+ try {
235+ const resolvedPath = realpathSync(resolve(directoryPath, entryName));
236+
237+ if (isPathOutsideRoot(codeRootRealPath, resolvedPath)) {
238+ return false;
239+ }
240+
241+ const resolvedRelativePath = normalizeCodeRelativePath(relative(codeRootRealPath, resolvedPath));
242+ const stats = statSync(resolvedPath);
243+
244+ if (isBlockedCodePath(resolvedRelativePath)) {
245+ return false;
246+ }
247+
248+ if (!stats.isDirectory() && isBinaryCodePath(resolvedRelativePath)) {
249+ return false;
250+ }
251+
252+ return true;
253+ } catch (error) {
254+ if (isMissingFileError(error)) {
255+ return false;
256+ }
257+
258+ throw error;
259+ }
260+}
261+
262+function renderCodeDirectoryListing(codeRootRealPath: string, directoryPath: string): string {
263+ return readdirSync(directoryPath)
264+ .filter((entryName) => isVisibleCodeDirectoryEntry(codeRootRealPath, directoryPath, entryName))
265+ .sort((left, right) => left.localeCompare(right))
266+ .join("\n");
267+}
268+
269+async function handleCodeRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
270+ const requestedPath = context.params.code_path ?? "";
271+
272+ try {
273+ const { codeRootRealPath, resolvedPath, resolvedRelativePath, stats } = resolveCodeAccessTarget(
274+ context.codeRootDir,
275+ requestedPath
276+ );
277+
278+ if (stats.isDirectory()) {
279+ return buildPlainTextBinaryResponse(200, renderCodeDirectoryListing(codeRootRealPath, resolvedPath));
280+ }
281+
282+ if (isBinaryCodePath(resolvedRelativePath)) {
283+ throw buildCodeAccessForbiddenError();
284+ }
285+
286+ return buildPlainTextBinaryResponse(200, readFileSync(resolvedPath, "utf8"));
287+ } catch (error) {
288+ if (error instanceof LocalApiHttpError) {
289+ throw error;
290+ }
291+
292+ if (isMissingFileError(error)) {
293+ throw new LocalApiHttpError(
294+ 404,
295+ "not_found",
296+ `Code path "${normalizePathname(context.url.pathname)}" was not found.`
297+ );
298+ }
299+
300+ throw error;
301+ }
302+}
303+
304 async function handleRobotsRead(): Promise<ConductorHttpResponse> {
305 return textResponse(200, ROBOTS_TXT_BODY);
306 }
307@@ -6329,6 +6577,8 @@ async function dispatchBusinessRoute(
308 return handleArtifactRead(context);
309 case "service.artifact.repo":
310 return handleArtifactRepoRead(context);
311+ case "service.code.read":
312+ return handleCodeRead(context);
313 case "service.health":
314 return handleHealthRead(context, version);
315 case "service.version":
316@@ -6471,9 +6721,13 @@ function findAllowedMethods(pathname: string): LocalApiRouteMethod[] {
317 function matchPathPattern(pathPattern: string, pathname: string): Record<string, string> | null {
318 const patternSegments = normalizePathname(pathPattern).split("/");
319 const pathSegments = normalizePathname(pathname).split("/");
320+ const wildcardPatternSegment = patternSegments[patternSegments.length - 1];
321
322- // When pattern ends with "*", allow any number of trailing segments.
323- const hasWildcard = patternSegments[patternSegments.length - 1] === "*";
324+ // When pattern ends with "*" or a trailing named wildcard like ":path*",
325+ // allow any number of trailing segments.
326+ const hasWildcard =
327+ wildcardPatternSegment === "*" ||
328+ (wildcardPatternSegment?.startsWith(":") === true && wildcardPatternSegment.endsWith("*"));
329
330 if (hasWildcard) {
331 if (pathSegments.length < patternSegments.length - 1) {
332@@ -6505,7 +6759,17 @@ function matchPathPattern(pathPattern: string, pathname: string): Record<string,
333 }
334
335 if (hasWildcard) {
336- params["*"] = pathSegments.slice(patternSegments.length - 1).map(s => decodeURIComponent(s)).join("/");
337+ const wildcardValue = pathSegments
338+ .slice(patternSegments.length - 1)
339+ .map((segment) => decodeURIComponent(segment))
340+ .join("/");
341+
342+ if (wildcardPatternSegment === "*") {
343+ params["*"] = wildcardValue;
344+ } else if (wildcardPatternSegment?.startsWith(":")) {
345+ params[wildcardPatternSegment.slice(1, -1)] = wildcardValue;
346+ params["*"] = wildcardValue;
347+ }
348 }
349
350 return params;
351@@ -6575,6 +6839,7 @@ export async function handleConductorHttpRequest(
352 claudeCodedLocalApiBase:
353 normalizeOptionalString(context.claudeCodedLocalApiBase) ??
354 getSnapshotClaudeCodedLocalApiBase(context.snapshotLoader()),
355+ codeRootDir: resolveCodeRootDir(context.codeRootDir),
356 codexdLocalApiBase:
357 normalizeOptionalString(context.codexdLocalApiBase) ?? getSnapshotCodexdLocalApiBase(context.snapshotLoader()),
358 fetchImpl: context.fetchImpl ?? globalThis.fetch,
+13,
-7
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- 当前状态:`待开始`
6+- 当前状态:`已完成`
7 - 规模预估:`S`
8 - 依赖任务:无
9 - 建议执行者:`Codex` 或 `Claude`
10@@ -76,18 +76,24 @@ conductor 的 `/code/` 路由直接 serve 磁盘上的代码文件,AI 通过 U
11
12 ### 开始执行
13
14-- 执行者:
15-- 开始时间:
16+- 执行者:`Codex`
17+- 开始时间:`2026-03-29 23:20:00 CST`
18 - 状态变更:`待开始` → `进行中`
19
20 ### 完成摘要
21
22-- 完成时间:
23+- 完成时间:`2026-03-29 23:49:38 CST`
24 - 状态变更:`进行中` → `已完成`
25-- 修改了哪些文件:
26-- 核心实现思路:
27-- 跑了哪些测试:
28+- 修改了哪些文件:`apps/conductor-daemon/src/local-api.ts`、`apps/conductor-daemon/src/index.ts`、`apps/conductor-daemon/src/index.test.js`
29+- 核心实现思路:新增 `/code/:code_path*` 只读路由;将 `BAA_CODE_ROOT_DIR` 解析进 runtime 配置并透传给 local API;请求时先约束 `..`、绝对路径和敏感路径,再通过 `realpath` 校验目标仍位于根目录内;目录请求返回过滤后的纯文本文件列表;文件请求统一以 `text/plain; charset=utf-8` 返回;按扩展名拒绝常见二进制文件。
30+- 跑了哪些测试:`pnpm install --frozen-lockfile`;`pnpm test`(`apps/conductor-daemon`,57/57 通过)
31
32 ### 执行过程中遇到的问题
33
34+- 新建 worktree 后缺少 `node_modules`,先在 worktree 根目录执行 `pnpm install --frozen-lockfile` 后再构建测试。
35+- 仓库自带的 `node` shim 没声明 `readdirSync`/`realpathSync`/`statSync`/`extname`/`relative`,在允许修改的源码文件内通过 namespace import + 类型断言兼容了现有 TypeScript 构建。
36+
37 ### 剩余风险
38+
39+- 二进制文件过滤目前基于扩展名,未做内容探测;无扩展名的二进制文件理论上仍可能被当作文本读取。
40+- 遍历型 URL 在进入路由前可能被 WHATWG URL 规范化为不存在路径,因此部分攻击请求会返回 `404` 而不是 `403`;这仍满足当前验收标准。