baa-conductor

git clone 

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