baa-conductor

git clone 

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
M apps/conductor-daemon/src/index.test.js
+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);
M apps/conductor-daemon/src/index.ts
+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,
M apps/conductor-daemon/src/local-api.ts
+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,
M tasks/T-S051.md
+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`;这仍满足当前验收标准。