- commit
- 50e7bf8
- parent
- f62d4bc
- author
- im_wower
- date
- 2026-03-29 23:50:27 +0800 CST
feat: serve code files from conductor local api
4 files changed,
+421,
-14
+116,
-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,119 @@ 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+ ".git",
72+ "package.json",
73+ "src"
74+ ]);
75+
76+ const hiddenEnvResponse = await handleConductorHttpRequest(
77+ {
78+ method: "GET",
79+ path: "/code/demo-repo/.env"
80+ },
81+ context
82+ );
83+ assert.equal(hiddenEnvResponse.status, 403);
84+ assert.equal(parseJsonBody(hiddenEnvResponse).error, "forbidden");
85+
86+ const hiddenCredentialsResponse = await handleConductorHttpRequest(
87+ {
88+ method: "GET",
89+ path: "/code/demo-repo/.credentials"
90+ },
91+ context
92+ );
93+ assert.equal(hiddenCredentialsResponse.status, 403);
94+ assert.equal(parseJsonBody(hiddenCredentialsResponse).error, "forbidden");
95+
96+ const hiddenGitObjectsResponse = await handleConductorHttpRequest(
97+ {
98+ method: "GET",
99+ path: "/code/demo-repo/.git/objects/secret"
100+ },
101+ context
102+ );
103+ assert.equal(hiddenGitObjectsResponse.status, 403);
104+ assert.equal(parseJsonBody(hiddenGitObjectsResponse).error, "forbidden");
105+
106+ const traversalResponse = await handleConductorHttpRequest(
107+ {
108+ method: "GET",
109+ path: "/code/demo-repo/%2e%2e/%2e%2e/%2e%2e/etc/passwd"
110+ },
111+ context
112+ );
113+ assert.ok([403, 404].includes(traversalResponse.status));
114+ assert.match(parseJsonBody(traversalResponse).error, /^(forbidden|not_found)$/u);
115+
116+ const binaryResponse = await handleConductorHttpRequest(
117+ {
118+ method: "GET",
119+ path: "/code/demo-repo/image.png"
120+ },
121+ context
122+ );
123+ assert.equal(binaryResponse.status, 403);
124+ assert.equal(parseJsonBody(binaryResponse).error, "forbidden");
125+
126+ const missingResponse = await handleConductorHttpRequest(
127+ {
128+ method: "GET",
129+ path: "/code/demo-repo/missing.ts"
130+ },
131+ context
132+ );
133+ assert.equal(missingResponse.status, 404);
134+ assert.equal(parseJsonBody(missingResponse).error, "not_found");
135+ } finally {
136+ rmSync(codeRootDir, {
137+ force: true,
138+ recursive: true
139+ });
140+ }
141+});
142+
143 test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status surfaces", async () => {
144 await withRuntimeFixture(async ({ runtime }) => {
145 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,
+275,
-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,44 @@ 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_BINARY_EXTENSIONS = new Set([
44+ ".7z",
45+ ".a",
46+ ".avi",
47+ ".class",
48+ ".db",
49+ ".dll",
50+ ".dylib",
51+ ".eot",
52+ ".exe",
53+ ".gif",
54+ ".gz",
55+ ".ico",
56+ ".jar",
57+ ".jpeg",
58+ ".jpg",
59+ ".mkv",
60+ ".mov",
61+ ".mp3",
62+ ".mp4",
63+ ".o",
64+ ".otf",
65+ ".pdf",
66+ ".png",
67+ ".pyc",
68+ ".rar",
69+ ".so",
70+ ".sqlite",
71+ ".tar",
72+ ".tgz",
73+ ".ttf",
74+ ".war",
75+ ".webp",
76+ ".woff",
77+ ".woff2",
78+ ".zip"
79+]);
80 const ARTIFACT_FILE_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
81 const RECENT_SESSIONS_TXT_ARTIFACT_PATH = buildArtifactRelativePath("session", "latest.txt");
82 const RECENT_SESSIONS_JSON_ARTIFACT_PATH = buildArtifactRelativePath("session", "latest.json");
83@@ -210,6 +262,7 @@ type UpstreamErrorEnvelope = JsonObject & {
84 interface LocalApiRequestContext {
85 artifactStore: ArtifactStore | null;
86 claudeCodedLocalApiBase: string | null;
87+ codeRootDir: string;
88 deliveryBridge: BaaBrowserDeliveryBridge | null;
89 browserBridge: BrowserBridgeController | null;
90 browserRequestPolicy: BrowserRequestPolicyController | null;
91@@ -263,6 +316,7 @@ export interface ConductorRuntimeApiSnapshot {
92 export interface ConductorLocalApiContext {
93 artifactStore?: ArtifactStore | null;
94 claudeCodedLocalApiBase?: string | null;
95+ codeRootDir?: string | null;
96 deliveryBridge?: BaaBrowserDeliveryBridge | null;
97 browserBridge?: BrowserBridgeController | null;
98 browserRequestPolicy?: BrowserRequestPolicyController | null;
99@@ -366,6 +420,13 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
100 pathPattern: "/artifact/repo/:repo_name/*",
101 summary: "读取 stagit 生成的 git 仓库静态页面"
102 },
103+ {
104+ id: "service.code.read",
105+ kind: "read",
106+ method: "GET",
107+ pathPattern: "/code/:code_path*",
108+ summary: "读取代码根目录下的文本源码文件,目录请求返回纯文本列表"
109+ },
110 {
111 id: "service.health",
112 kind: "read",
113@@ -5809,6 +5870,198 @@ async function handleArtifactRepoRead(context: LocalApiRequestContext): Promise<
114 }
115 }
116
117+function buildPlainTextBinaryResponse(status: number, body: string): ConductorHttpResponse {
118+ return binaryResponse(status, Buffer.from(body, "utf8"), {
119+ "content-type": CODE_ROUTE_CONTENT_TYPE
120+ });
121+}
122+
123+function resolveCodeRootDir(value: string | null | undefined): string {
124+ return resolve(normalizeOptionalString(value) ?? DEFAULT_CODE_ROOT_DIR);
125+}
126+
127+function normalizeCodeRelativePath(value: string): string {
128+ return value.split(/[\\/]+/u).filter(Boolean).join("/");
129+}
130+
131+function isBlockedCodePath(relativePath: string): boolean {
132+ const normalized = normalizeCodeRelativePath(relativePath);
133+ const segments = normalized === "" ? [] : normalized.split("/");
134+
135+ if (segments.some((segment) => BLOCKED_CODE_FILE_NAMES.has(segment))) {
136+ return true;
137+ }
138+
139+ for (let index = 0; index < segments.length - 1; index += 1) {
140+ if (segments[index] === ".git" && segments[index + 1] === "objects") {
141+ return true;
142+ }
143+ }
144+
145+ return false;
146+}
147+
148+function isBinaryCodePath(relativePath: string): boolean {
149+ const normalized = normalizeCodeRelativePath(relativePath);
150+ return normalized !== "" && BLOCKED_CODE_BINARY_EXTENSIONS.has(extname(normalized).toLowerCase());
151+}
152+
153+function isPathOutsideRoot(rootPath: string, targetPath: string): boolean {
154+ const relativePath = relative(rootPath, targetPath);
155+
156+ if (relativePath === "") {
157+ return false;
158+ }
159+
160+ const [firstSegment = ""] = relativePath.split(/[\\/]+/u).filter(Boolean);
161+ return firstSegment === "..";
162+}
163+
164+function buildCodeAccessForbiddenError(): LocalApiHttpError {
165+ return new LocalApiHttpError(403, "forbidden", "Requested code path is not allowed.");
166+}
167+
168+function assertAllowedRequestedCodePath(relativePath: string): void {
169+ if (relativePath.includes("\u0000") || relativePath.includes("\\") || relativePath.startsWith("/")) {
170+ throw buildCodeAccessForbiddenError();
171+ }
172+
173+ const segments = relativePath === "" ? [] : relativePath.split("/");
174+
175+ if (segments.some((segment) => segment === "" || segment === "..")) {
176+ throw buildCodeAccessForbiddenError();
177+ }
178+
179+ if (isBlockedCodePath(relativePath)) {
180+ throw buildCodeAccessForbiddenError();
181+ }
182+}
183+
184+function getCodeRootRealPath(codeRootDir: string): string {
185+ try {
186+ return realpathSync(codeRootDir);
187+ } catch (error) {
188+ if (isMissingFileError(error)) {
189+ throw new LocalApiHttpError(
190+ 500,
191+ "code_root_not_found",
192+ `Configured code root "${codeRootDir}" does not exist.`
193+ );
194+ }
195+
196+ throw error;
197+ }
198+}
199+
200+function resolveCodeAccessTarget(codeRootDir: string, requestedPath: string): {
201+ codeRootRealPath: string;
202+ resolvedPath: string;
203+ resolvedRelativePath: string;
204+ stats: ReturnType<typeof statSync>;
205+} {
206+ assertAllowedRequestedCodePath(requestedPath);
207+ const codeRootRealPath = getCodeRootRealPath(codeRootDir);
208+ const resolvedPath = realpathSync(resolve(codeRootRealPath, requestedPath === "" ? "." : requestedPath));
209+
210+ if (isPathOutsideRoot(codeRootRealPath, resolvedPath)) {
211+ throw buildCodeAccessForbiddenError();
212+ }
213+
214+ const resolvedRelativePath = normalizeCodeRelativePath(relative(codeRootRealPath, resolvedPath));
215+
216+ if (isBlockedCodePath(resolvedRelativePath)) {
217+ throw buildCodeAccessForbiddenError();
218+ }
219+
220+ return {
221+ codeRootRealPath,
222+ resolvedPath,
223+ resolvedRelativePath,
224+ stats: statSync(resolvedPath)
225+ };
226+}
227+
228+function isVisibleCodeDirectoryEntry(
229+ codeRootRealPath: string,
230+ directoryPath: string,
231+ entryName: string
232+): boolean {
233+ const visibleRelativePath = normalizeCodeRelativePath(relative(codeRootRealPath, resolve(directoryPath, entryName)));
234+
235+ if (isBlockedCodePath(visibleRelativePath)) {
236+ return false;
237+ }
238+
239+ try {
240+ const resolvedPath = realpathSync(resolve(directoryPath, entryName));
241+
242+ if (isPathOutsideRoot(codeRootRealPath, resolvedPath)) {
243+ return false;
244+ }
245+
246+ const resolvedRelativePath = normalizeCodeRelativePath(relative(codeRootRealPath, resolvedPath));
247+ const stats = statSync(resolvedPath);
248+
249+ if (isBlockedCodePath(resolvedRelativePath)) {
250+ return false;
251+ }
252+
253+ if (!stats.isDirectory() && isBinaryCodePath(resolvedRelativePath)) {
254+ return false;
255+ }
256+
257+ return true;
258+ } catch (error) {
259+ if (isMissingFileError(error)) {
260+ return false;
261+ }
262+
263+ throw error;
264+ }
265+}
266+
267+function renderCodeDirectoryListing(codeRootRealPath: string, directoryPath: string): string {
268+ return readdirSync(directoryPath)
269+ .filter((entryName) => isVisibleCodeDirectoryEntry(codeRootRealPath, directoryPath, entryName))
270+ .sort((left, right) => left.localeCompare(right))
271+ .join("\n");
272+}
273+
274+async function handleCodeRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
275+ const requestedPath = context.params.code_path ?? "";
276+
277+ try {
278+ const { codeRootRealPath, resolvedPath, resolvedRelativePath, stats } = resolveCodeAccessTarget(
279+ context.codeRootDir,
280+ requestedPath
281+ );
282+
283+ if (stats.isDirectory()) {
284+ return buildPlainTextBinaryResponse(200, renderCodeDirectoryListing(codeRootRealPath, resolvedPath));
285+ }
286+
287+ if (isBinaryCodePath(resolvedRelativePath)) {
288+ throw buildCodeAccessForbiddenError();
289+ }
290+
291+ return buildPlainTextBinaryResponse(200, readFileSync(resolvedPath, "utf8"));
292+ } catch (error) {
293+ if (error instanceof LocalApiHttpError) {
294+ throw error;
295+ }
296+
297+ if (isMissingFileError(error)) {
298+ throw new LocalApiHttpError(
299+ 404,
300+ "not_found",
301+ `Code path "${normalizePathname(context.url.pathname)}" was not found.`
302+ );
303+ }
304+
305+ throw error;
306+ }
307+}
308+
309 async function handleRobotsRead(): Promise<ConductorHttpResponse> {
310 return textResponse(200, ROBOTS_TXT_BODY);
311 }
312@@ -6329,6 +6582,8 @@ async function dispatchBusinessRoute(
313 return handleArtifactRead(context);
314 case "service.artifact.repo":
315 return handleArtifactRepoRead(context);
316+ case "service.code.read":
317+ return handleCodeRead(context);
318 case "service.health":
319 return handleHealthRead(context, version);
320 case "service.version":
321@@ -6471,9 +6726,13 @@ function findAllowedMethods(pathname: string): LocalApiRouteMethod[] {
322 function matchPathPattern(pathPattern: string, pathname: string): Record<string, string> | null {
323 const patternSegments = normalizePathname(pathPattern).split("/");
324 const pathSegments = normalizePathname(pathname).split("/");
325+ const wildcardPatternSegment = patternSegments[patternSegments.length - 1];
326
327- // When pattern ends with "*", allow any number of trailing segments.
328- const hasWildcard = patternSegments[patternSegments.length - 1] === "*";
329+ // When pattern ends with "*" or a trailing named wildcard like ":path*",
330+ // allow any number of trailing segments.
331+ const hasWildcard =
332+ wildcardPatternSegment === "*" ||
333+ (wildcardPatternSegment?.startsWith(":") === true && wildcardPatternSegment.endsWith("*"));
334
335 if (hasWildcard) {
336 if (pathSegments.length < patternSegments.length - 1) {
337@@ -6505,7 +6764,17 @@ function matchPathPattern(pathPattern: string, pathname: string): Record<string,
338 }
339
340 if (hasWildcard) {
341- params["*"] = pathSegments.slice(patternSegments.length - 1).map(s => decodeURIComponent(s)).join("/");
342+ const wildcardValue = pathSegments
343+ .slice(patternSegments.length - 1)
344+ .map((segment) => decodeURIComponent(segment))
345+ .join("/");
346+
347+ if (wildcardPatternSegment === "*") {
348+ params["*"] = wildcardValue;
349+ } else if (wildcardPatternSegment?.startsWith(":")) {
350+ params[wildcardPatternSegment.slice(1, -1)] = wildcardValue;
351+ params["*"] = wildcardValue;
352+ }
353 }
354
355 return params;
356@@ -6575,6 +6844,7 @@ export async function handleConductorHttpRequest(
357 claudeCodedLocalApiBase:
358 normalizeOptionalString(context.claudeCodedLocalApiBase) ??
359 getSnapshotClaudeCodedLocalApiBase(context.snapshotLoader()),
360+ codeRootDir: resolveCodeRootDir(context.codeRootDir),
361 codexdLocalApiBase:
362 normalizeOptionalString(context.codexdLocalApiBase) ?? getSnapshotCodexdLocalApiBase(context.snapshotLoader()),
363 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`;这仍满足当前验收标准。