- commit
- 1e75818
- parent
- a7ba96e
- author
- im_wower
- date
- 2026-03-23 01:02:54 +0800 CST
feat(conductor-daemon): proxy codex routes to codexd
10 files changed,
+1225,
-258
+2,
-2
1@@ -94,7 +94,7 @@
2 - 持续记录日志
3 - 公开接口结论已经明确:
4 - 主接口:`codex app-server`
5- - 辅助兜底:`codex exec`
6+ - `conductor-daemon` 对外只保留 `/v1/codex`、`/v1/codex/sessions`、`/v1/codex/sessions/:session_id`、`POST /v1/codex/sessions`、`POST /v1/codex/turn`
7 - 不驱动 TUI
8 - 负责 Codex 特有的会话、子进程、重试、超时和恢复语义
9 - 必须作为独立进程运行,不接受长期内嵌在 `conductor-daemon` 里的方案
10@@ -110,7 +110,7 @@
11 - 仓库里已经有 `codex` step kind、planner 和 step template 抽象
12 - `apps/codexd` 已有 daemon scaffold,但还没有完成 conductor 集成
13 - 所以 `codexd` 现在仍是半成品,不是已落地组件
14-- 后续实现时,不再把 `exec` 当作主双工方案;`exec` 只用于简单调用、测试和兜底
15+- 正式产品口径只保留 session / turn / status,不把 `runs` / `exec` 当作 Codex 能力面
16 - 后续实现时,`conductor-daemon` 只能通过本地接口调用 `codexd`,不能自己长期维护一套并行 bridge
17
18 ### `apps/worker-runner`
+602,
-188
1@@ -1,4 +1,5 @@
2 import assert from "node:assert/strict";
3+import { createServer } from "node:http";
4 import { mkdtempSync, rmSync } from "node:fs";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7@@ -179,6 +180,9 @@ async function createLocalApiFixture() {
8 });
9
10 const snapshot = {
11+ codexd: {
12+ localApiBase: null
13+ },
14 controlApi: {
15 baseUrl: "https://control.example.test",
16 firefoxWsUrl: "ws://127.0.0.1:4317/ws/firefox",
17@@ -214,6 +218,250 @@ async function createLocalApiFixture() {
18 };
19 }
20
21+async function startCodexdStubServer() {
22+ const requests = [];
23+ const sessions = [
24+ {
25+ sessionId: "session-demo",
26+ purpose: "duplex",
27+ threadId: "thread-demo",
28+ status: "active",
29+ endpoint: "http://127.0.0.1:0",
30+ childPid: 43210,
31+ createdAt: "2026-03-23T00:00:00.000Z",
32+ updatedAt: "2026-03-23T00:00:00.000Z",
33+ cwd: "/Users/george/code/baa-conductor",
34+ model: "gpt-5.4",
35+ modelProvider: "openai",
36+ serviceTier: "default",
37+ reasoningEffort: "medium",
38+ currentTurnId: null,
39+ lastTurnId: "turn-demo",
40+ lastTurnStatus: "completed",
41+ metadata: {
42+ origin: "stub"
43+ }
44+ }
45+ ];
46+
47+ const server = createServer(async (request, response) => {
48+ const method = (request.method ?? "GET").toUpperCase();
49+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
50+ let rawBody = "";
51+
52+ request.setEncoding("utf8");
53+ for await (const chunk of request) {
54+ rawBody += chunk;
55+ }
56+
57+ const parsedBody = rawBody === "" ? null : JSON.parse(rawBody);
58+ requests.push({
59+ body: parsedBody,
60+ method,
61+ path: url.pathname
62+ });
63+
64+ const address = server.address();
65+ const port = typeof address === "object" && address ? address.port : 0;
66+ const baseUrl = `http://127.0.0.1:${port}`;
67+ const eventStreamUrl = `ws://127.0.0.1:${port}/v1/codexd/events`;
68+
69+ const writeJson = (status, payload) => {
70+ response.statusCode = status;
71+ response.setHeader("content-type", "application/json; charset=utf-8");
72+ response.end(`${JSON.stringify(payload, null, 2)}\n`);
73+ };
74+
75+ const currentSessions = sessions.map((session) => ({
76+ ...session,
77+ endpoint: baseUrl
78+ }));
79+
80+ if (method === "GET" && url.pathname === "/v1/codexd/status") {
81+ writeJson(200, {
82+ ok: true,
83+ data: {
84+ service: {
85+ configuredBaseUrl: baseUrl,
86+ eventStreamPath: "/v1/codexd/events",
87+ eventStreamUrl,
88+ listening: true,
89+ resolvedBaseUrl: baseUrl,
90+ websocketClients: 0
91+ },
92+ snapshot: {
93+ identity: {
94+ daemonId: "codexd-demo",
95+ nodeId: "mini",
96+ repoRoot: "/Users/george/code/baa-conductor",
97+ version: "1.2.3"
98+ },
99+ daemon: {
100+ started: true,
101+ startedAt: "2026-03-23T00:00:00.000Z",
102+ updatedAt: "2026-03-23T00:00:01.000Z",
103+ child: {
104+ endpoint: baseUrl,
105+ lastError: null,
106+ pid: 43210,
107+ status: "running",
108+ strategy: "spawn"
109+ }
110+ },
111+ sessionRegistry: {
112+ updatedAt: "2026-03-23T00:00:02.000Z",
113+ sessions: currentSessions
114+ },
115+ recentEvents: {
116+ updatedAt: "2026-03-23T00:00:03.000Z",
117+ events: [
118+ {
119+ seq: 1,
120+ createdAt: "2026-03-23T00:00:03.000Z",
121+ level: "info",
122+ type: "session.created",
123+ message: "Created session session-demo.",
124+ detail: {
125+ sessionId: "session-demo",
126+ threadId: "thread-demo"
127+ }
128+ }
129+ ]
130+ }
131+ }
132+ }
133+ });
134+ return;
135+ }
136+
137+ if (method === "GET" && url.pathname === "/v1/codexd/sessions") {
138+ writeJson(200, {
139+ ok: true,
140+ data: {
141+ sessions: currentSessions
142+ }
143+ });
144+ return;
145+ }
146+
147+ if (method === "POST" && url.pathname === "/v1/codexd/sessions") {
148+ const nextSession = {
149+ ...sessions[0],
150+ sessionId: `session-${sessions.length + 1}`,
151+ purpose: parsedBody?.purpose ?? "duplex",
152+ cwd: parsedBody?.cwd ?? sessions[0].cwd,
153+ model: parsedBody?.model ?? sessions[0].model,
154+ updatedAt: "2026-03-23T00:00:04.000Z"
155+ };
156+ sessions.push(nextSession);
157+ writeJson(201, {
158+ ok: true,
159+ data: {
160+ session: {
161+ ...nextSession,
162+ endpoint: baseUrl
163+ }
164+ }
165+ });
166+ return;
167+ }
168+
169+ if (method === "POST" && url.pathname === "/v1/codexd/turn") {
170+ const sessionId = parsedBody?.sessionId;
171+ const session = sessions.find((entry) => entry.sessionId === sessionId);
172+
173+ if (!session) {
174+ writeJson(404, {
175+ ok: false,
176+ error: "not_found",
177+ message: `Unknown codexd session "${sessionId}".`
178+ });
179+ return;
180+ }
181+
182+ session.lastTurnId = "turn-created";
183+ session.lastTurnStatus = "accepted";
184+ session.updatedAt = "2026-03-23T00:00:05.000Z";
185+ writeJson(202, {
186+ ok: true,
187+ data: {
188+ accepted: true,
189+ session: {
190+ ...session,
191+ endpoint: baseUrl
192+ },
193+ turnId: "turn-created"
194+ }
195+ });
196+ return;
197+ }
198+
199+ const sessionMatch = url.pathname.match(/^\/v1\/codexd\/sessions\/([^/]+)$/u);
200+
201+ if (method === "GET" && sessionMatch?.[1]) {
202+ const session = sessions.find((entry) => entry.sessionId === decodeURIComponent(sessionMatch[1]));
203+
204+ if (!session) {
205+ writeJson(404, {
206+ ok: false,
207+ error: "not_found",
208+ message: `Unknown codexd session "${decodeURIComponent(sessionMatch[1])}".`
209+ });
210+ return;
211+ }
212+
213+ writeJson(200, {
214+ ok: true,
215+ data: {
216+ session: {
217+ ...session,
218+ endpoint: baseUrl
219+ },
220+ recentEvents: [
221+ {
222+ seq: 1,
223+ type: "turn.completed"
224+ }
225+ ]
226+ }
227+ });
228+ return;
229+ }
230+
231+ writeJson(404, {
232+ ok: false,
233+ error: "not_found",
234+ message: `Unknown codexd route ${method} ${url.pathname}.`
235+ });
236+ });
237+
238+ await new Promise((resolve, reject) => {
239+ server.once("error", reject);
240+ server.listen(0, "127.0.0.1", resolve);
241+ });
242+
243+ const address = server.address();
244+ const port = typeof address === "object" && address ? address.port : 0;
245+
246+ return {
247+ baseUrl: `http://127.0.0.1:${port}`,
248+ requests,
249+ async stop() {
250+ await new Promise((resolve, reject) => {
251+ server.close((error) => {
252+ if (error) {
253+ reject(error);
254+ return;
255+ }
256+
257+ resolve();
258+ });
259+ server.closeAllConnections?.();
260+ });
261+ }
262+ };
263+}
264+
265 function parseJsonBody(response) {
266 return JSON.parse(response.body);
267 }
268@@ -577,6 +825,7 @@ test("parseConductorCliRequest merges launchd env defaults with CLI overrides",
269 BAA_CONDUCTOR_HOST: "mini",
270 BAA_CONDUCTOR_ROLE: "primary",
271 BAA_CONTROL_API_BASE: "https://control.example.test/",
272+ BAA_CODEXD_LOCAL_API_BASE: "http://127.0.0.1:4323/",
273 BAA_CONDUCTOR_LOCAL_API: "http://127.0.0.1:4317/",
274 BAA_SHARED_TOKEN: "replace-me",
275 BAA_RUNS_DIR: "/tmp/runs"
276@@ -592,6 +841,7 @@ test("parseConductorCliRequest merges launchd env defaults with CLI overrides",
277 assert.equal(request.config.role, "standby");
278 assert.equal(request.config.nodeId, "mini-main");
279 assert.equal(request.config.controlApiBase, "https://control.example.test");
280+ assert.equal(request.config.codexdLocalApiBase, "http://127.0.0.1:4323");
281 assert.equal(request.config.localApiBase, "http://127.0.0.1:4317");
282 assert.equal(request.config.paths.runsDir, "/tmp/runs");
283 });
284@@ -648,6 +898,9 @@ test("parseConductorCliRequest rejects unlisted or non-Tailscale local API hosts
285
286 test("handleConductorHttpRequest keeps degraded runtimes observable but not ready", async () => {
287 const snapshot = {
288+ codexd: {
289+ localApiBase: null
290+ },
291 daemon: {
292 nodeId: "mini-main",
293 host: "mini",
294@@ -708,11 +961,15 @@ test("handleConductorHttpRequest keeps degraded runtimes observable but not read
295
296 test("handleConductorHttpRequest serves the migrated local business endpoints from the local repository", async () => {
297 const { repository, sharedToken, snapshot } = await createLocalApiFixture();
298+ const codexd = await startCodexdStubServer();
299 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-local-host-http-"));
300 const authorizedHeaders = {
301 authorization: `Bearer ${sharedToken}`
302 };
303+ snapshot.codexd.localApiBase = codexd.baseUrl;
304 const localApiContext = {
305+ codexdLocalApiBase: codexd.baseUrl,
306+ fetchImpl: globalThis.fetch,
307 repository,
308 sharedToken,
309 snapshotLoader: () => snapshot
310@@ -737,9 +994,13 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
311 assert.equal(describePayload.data.system.mode, "running");
312 assert.equal(describePayload.data.describe_endpoints.business.path, "/describe/business");
313 assert.equal(describePayload.data.describe_endpoints.control.path, "/describe/control");
314+ assert.equal(describePayload.data.codex.enabled, true);
315+ assert.equal(describePayload.data.codex.target_base_url, codexd.baseUrl);
316 assert.equal(describePayload.data.host_operations.enabled, true);
317 assert.equal(describePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
318 assert.equal(describePayload.data.host_operations.auth.configured, true);
319+ assert.doesNotMatch(JSON.stringify(describePayload.data.codex.routes), /\/v1\/codex\/runs/u);
320+ assert.doesNotMatch(JSON.stringify(describePayload.data.capabilities.read_endpoints), /\/v1\/runs/u);
321
322 const businessDescribeResponse = await handleConductorHttpRequest(
323 {
324@@ -752,8 +1013,11 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
325 const businessDescribePayload = parseJsonBody(businessDescribeResponse);
326 assert.equal(businessDescribePayload.data.surface, "business");
327 assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/tasks/u);
328+ assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/codex/u);
329 assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
330 assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/exec/u);
331+ assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/runs/u);
332+ assert.equal(businessDescribePayload.data.codex.backend, "independent_codexd");
333
334 const controlDescribeResponse = await handleConductorHttpRequest(
335 {
336@@ -768,6 +1032,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
337 assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
338 assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/exec/u);
339 assert.doesNotMatch(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/tasks/u);
340+ assert.equal(controlDescribePayload.data.codex.target_base_url, codexd.baseUrl);
341 assert.equal(controlDescribePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
342
343 const healthResponse = await handleConductorHttpRequest(
344@@ -780,6 +1045,19 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
345 assert.equal(healthResponse.status, 200);
346 assert.equal(parseJsonBody(healthResponse).data.status, "ok");
347
348+ const capabilitiesResponse = await handleConductorHttpRequest(
349+ {
350+ method: "GET",
351+ path: "/v1/capabilities"
352+ },
353+ versionedLocalApiContext
354+ );
355+ assert.equal(capabilitiesResponse.status, 200);
356+ const capabilitiesPayload = parseJsonBody(capabilitiesResponse);
357+ assert.equal(capabilitiesPayload.data.codex.backend, "independent_codexd");
358+ assert.match(JSON.stringify(capabilitiesPayload.data.read_endpoints), /\/v1\/codex/u);
359+ assert.doesNotMatch(JSON.stringify(capabilitiesPayload.data.read_endpoints), /\/v1\/runs/u);
360+
361 const controllersResponse = await handleConductorHttpRequest(
362 {
363 method: "GET",
364@@ -835,6 +1113,77 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
365 assert.equal(runsPayload.data.count, 1);
366 assert.equal(runsPayload.data.runs[0].run_id, "run_demo");
367
368+ const codexStatusResponse = await handleConductorHttpRequest(
369+ {
370+ method: "GET",
371+ path: "/v1/codex"
372+ },
373+ localApiContext
374+ );
375+ assert.equal(codexStatusResponse.status, 200);
376+ const codexStatusPayload = parseJsonBody(codexStatusResponse);
377+ assert.equal(codexStatusPayload.data.backend, "independent_codexd");
378+ assert.equal(codexStatusPayload.data.proxy.target_base_url, codexd.baseUrl);
379+ assert.equal(codexStatusPayload.data.sessions.count, 1);
380+ assert.equal(codexStatusPayload.data.sessions.active_count, 1);
381+ assert.doesNotMatch(JSON.stringify(codexStatusPayload.data.routes), /\/v1\/codex\/runs/u);
382+
383+ const codexSessionsResponse = await handleConductorHttpRequest(
384+ {
385+ method: "GET",
386+ path: "/v1/codex/sessions"
387+ },
388+ localApiContext
389+ );
390+ assert.equal(codexSessionsResponse.status, 200);
391+ const codexSessionsPayload = parseJsonBody(codexSessionsResponse);
392+ assert.equal(codexSessionsPayload.data.sessions.length, 1);
393+ assert.equal(codexSessionsPayload.data.sessions[0].sessionId, "session-demo");
394+
395+ const codexSessionReadResponse = await handleConductorHttpRequest(
396+ {
397+ method: "GET",
398+ path: "/v1/codex/sessions/session-demo"
399+ },
400+ localApiContext
401+ );
402+ assert.equal(codexSessionReadResponse.status, 200);
403+ const codexSessionReadPayload = parseJsonBody(codexSessionReadResponse);
404+ assert.equal(codexSessionReadPayload.data.session.sessionId, "session-demo");
405+
406+ const codexSessionCreateResponse = await handleConductorHttpRequest(
407+ {
408+ body: JSON.stringify({
409+ cwd: "/Users/george/code/baa-conductor",
410+ model: "gpt-5.4",
411+ purpose: "duplex"
412+ }),
413+ method: "POST",
414+ path: "/v1/codex/sessions"
415+ },
416+ localApiContext
417+ );
418+ assert.equal(codexSessionCreateResponse.status, 201);
419+ const codexSessionCreatePayload = parseJsonBody(codexSessionCreateResponse);
420+ assert.equal(codexSessionCreatePayload.data.session.sessionId, "session-2");
421+ assert.equal(codexSessionCreatePayload.data.session.purpose, "duplex");
422+
423+ const codexTurnResponse = await handleConductorHttpRequest(
424+ {
425+ body: JSON.stringify({
426+ input: "Summarize pending work.",
427+ sessionId: "session-demo"
428+ }),
429+ method: "POST",
430+ path: "/v1/codex/turn"
431+ },
432+ localApiContext
433+ );
434+ assert.equal(codexTurnResponse.status, 202);
435+ const codexTurnPayload = parseJsonBody(codexTurnResponse);
436+ assert.equal(codexTurnPayload.data.accepted, true);
437+ assert.equal(codexTurnPayload.data.turnId, "turn-created");
438+
439 const runResponse = await handleConductorHttpRequest(
440 {
441 method: "GET",
442@@ -974,13 +1323,11 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
443 body: JSON.stringify({
444 command: ["echo", "hello"]
445 }),
446+ headers: authorizedHeaders,
447 method: "POST",
448 path: "/v1/exec"
449 },
450- {
451- repository,
452- snapshotLoader: () => snapshot
453- }
454+ localApiContext
455 );
456 assert.equal(invalidExecResponse.status, 200);
457 const invalidExecPayload = parseJsonBody(invalidExecResponse);
458@@ -988,28 +1335,6 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
459 assert.equal(invalidExecPayload.data.error.code, "INVALID_INPUT");
460 assertEmptyExecResultShape(invalidExecPayload.data.result);
461
462- const tccExecResponse = await withMockedPlatform("darwin", () =>
463- handleConductorHttpRequest(
464- {
465- body: JSON.stringify({
466- command: "pwd",
467- cwd: join(homedir(), "Desktop", "project"),
468- timeoutMs: 2000
469- }),
470- method: "POST",
471- path: "/v1/exec"
472- },
473- {
474- repository,
475- snapshotLoader: () => snapshot
476- }
477- );
478- assert.equal(execResponse.status, 200);
479- const execPayload = parseJsonBody(execResponse);
480- assert.equal(execPayload.data.ok, true);
481- assert.equal(execPayload.data.operation, "exec");
482- assert.equal(execPayload.data.result.stdout, "host-http-ok");
483-
484 const systemStateResponse = await handleConductorHttpRequest(
485 {
486 method: "GET",
487@@ -1018,15 +1343,56 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
488 localApiContext
489 );
490 assert.equal(parseJsonBody(systemStateResponse).data.mode, "paused");
491- } finally {
492- rmSync(hostOpsDir, {
493- force: true,
494- recursive: true
495- });
496+ } finally {
497+ await codexd.stop();
498+ rmSync(hostOpsDir, {
499+ force: true,
500+ recursive: true
501+ });
502 }
503-);
504+
505+ assert.deepEqual(
506+ codexd.requests.map((request) => request.path),
507+ [
508+ "/v1/codexd/status",
509+ "/v1/codexd/sessions",
510+ "/v1/codexd/sessions/session-demo",
511+ "/v1/codexd/sessions",
512+ "/v1/codexd/turn"
513+ ]
514+ );
515+ assert.equal(codexd.requests[3].body.model, "gpt-5.4");
516+ assert.equal(codexd.requests[4].body.sessionId, "session-demo");
517+});
518+
519+test("handleConductorHttpRequest returns a codexd-specific availability error when the proxy target is down", async () => {
520+ const { repository, snapshot } = await createLocalApiFixture();
521+ snapshot.codexd.localApiBase = "http://127.0.0.1:65535";
522+
523+ const response = await handleConductorHttpRequest(
524+ {
525+ method: "GET",
526+ path: "/v1/codex"
527+ },
528+ {
529+ codexdLocalApiBase: snapshot.codexd.localApiBase,
530+ fetchImpl: async () => {
531+ throw new Error("connect ECONNREFUSED");
532+ },
533+ repository,
534+ snapshotLoader: () => snapshot
535+ }
536+ );
537+
538+ assert.equal(response.status, 503);
539+ const payload = parseJsonBody(response);
540+ assert.equal(payload.error, "codexd_unavailable");
541+ assert.match(payload.message, /Independent codexd is unavailable/u);
542+ assert.doesNotMatch(payload.message, /bridge/u);
543+});
544
545 test("ConductorRuntime serves health and migrated local API endpoints over HTTP", async () => {
546+ const codexd = await startCodexdStubServer();
547 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-"));
548 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-host-"));
549 const runtime = new ConductorRuntime(
550@@ -1035,6 +1401,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
551 host: "mini",
552 role: "primary",
553 controlApiBase: "https://control.example.test",
554+ codexdLocalApiBase: codexd.baseUrl,
555 localApiBase: "http://127.0.0.1:0",
556 sharedToken: "replace-me",
557 paths: {
558@@ -1048,170 +1415,217 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
559 }
560 );
561
562- const snapshot = await runtime.start();
563- assert.equal(snapshot.daemon.schedulerEnabled, true);
564- assert.match(snapshot.controlApi.localApiBase, /^http:\/\/127\.0\.0\.1:\d+$/u);
565- assert.match(snapshot.controlApi.firefoxWsUrl, /^ws:\/\/127\.0\.0\.1:\d+\/ws\/firefox$/u);
566+ try {
567+ const snapshot = await runtime.start();
568+ assert.equal(snapshot.daemon.schedulerEnabled, true);
569+ assert.equal(snapshot.codexd.localApiBase, codexd.baseUrl);
570+ assert.match(snapshot.controlApi.localApiBase, /^http:\/\/127\.0\.0\.1:\d+$/u);
571+ assert.match(snapshot.controlApi.firefoxWsUrl, /^ws:\/\/127\.0\.0\.1:\d+\/ws\/firefox$/u);
572+
573+ const baseUrl = snapshot.controlApi.localApiBase;
574+ const hostOpsHeaders = {
575+ authorization: "Bearer replace-me",
576+ "content-type": "application/json"
577+ };
578
579- const baseUrl = snapshot.controlApi.localApiBase;
580- const hostOpsHeaders = {
581- authorization: "Bearer replace-me",
582- "content-type": "application/json"
583- };
584+ const healthResponse = await fetch(`${baseUrl}/healthz`);
585+ assert.equal(healthResponse.status, 200);
586+ assert.equal(await healthResponse.text(), "ok\n");
587+
588+ const apiHealthResponse = await fetch(`${baseUrl}/health`);
589+ assert.equal(apiHealthResponse.status, 200);
590+ const apiHealthPayload = await apiHealthResponse.json();
591+ assert.equal(apiHealthPayload.ok, true);
592+ assert.equal(apiHealthPayload.data.status, "ok");
593+
594+ const readyResponse = await fetch(`${baseUrl}/readyz`);
595+ assert.equal(readyResponse.status, 200);
596+ assert.equal(await readyResponse.text(), "ready\n");
597+
598+ const roleResponse = await fetch(`${baseUrl}/rolez`);
599+ assert.equal(roleResponse.status, 200);
600+ assert.equal(await roleResponse.text(), "leader\n");
601+
602+ const runtimeResponse = await fetch(`${baseUrl}/v1/runtime`);
603+ assert.equal(runtimeResponse.status, 200);
604+ const payload = await runtimeResponse.json();
605+ assert.equal(payload.ok, true);
606+ assert.equal(payload.data.identity, "mini-main@mini(primary)");
607+ assert.equal(payload.data.controlApi.firefoxWsUrl, snapshot.controlApi.firefoxWsUrl);
608+ assert.equal(payload.data.controlApi.localApiBase, baseUrl);
609+ assert.equal(payload.data.runtime.started, true);
610+
611+ const systemStateResponse = await fetch(`${baseUrl}/v1/system/state`);
612+ assert.equal(systemStateResponse.status, 200);
613+ const systemStatePayload = await systemStateResponse.json();
614+ assert.equal(systemStatePayload.ok, true);
615+ assert.equal(systemStatePayload.data.holder_id, "mini-main");
616+ assert.equal(systemStatePayload.data.mode, "running");
617+
618+ const codexStatusResponse = await fetch(`${baseUrl}/v1/codex`);
619+ assert.equal(codexStatusResponse.status, 200);
620+ const codexStatusPayload = await codexStatusResponse.json();
621+ assert.equal(codexStatusPayload.data.proxy.target_base_url, codexd.baseUrl);
622+ assert.equal(codexStatusPayload.data.sessions.count, 1);
623+
624+ const codexSessionsResponse = await fetch(`${baseUrl}/v1/codex/sessions`);
625+ assert.equal(codexSessionsResponse.status, 200);
626+ const codexSessionsPayload = await codexSessionsResponse.json();
627+ assert.equal(codexSessionsPayload.data.sessions[0].sessionId, "session-demo");
628+
629+ const codexSessionCreateResponse = await fetch(`${baseUrl}/v1/codex/sessions`, {
630+ method: "POST",
631+ headers: {
632+ "content-type": "application/json"
633+ },
634+ body: JSON.stringify({
635+ cwd: "/Users/george/code/baa-conductor",
636+ purpose: "duplex"
637+ })
638+ });
639+ assert.equal(codexSessionCreateResponse.status, 201);
640+ const codexSessionCreatePayload = await codexSessionCreateResponse.json();
641+ assert.equal(codexSessionCreatePayload.data.session.sessionId, "session-2");
642
643- const healthResponse = await fetch(`${baseUrl}/healthz`);
644- assert.equal(healthResponse.status, 200);
645- assert.equal(await healthResponse.text(), "ok\n");
646+ const codexTurnResponse = await fetch(`${baseUrl}/v1/codex/turn`, {
647+ method: "POST",
648+ headers: {
649+ "content-type": "application/json"
650+ },
651+ body: JSON.stringify({
652+ input: "Continue.",
653+ sessionId: "session-demo"
654+ })
655+ });
656+ assert.equal(codexTurnResponse.status, 202);
657+ const codexTurnPayload = await codexTurnResponse.json();
658+ assert.equal(codexTurnPayload.data.accepted, true);
659+ assert.equal(codexTurnPayload.data.turnId, "turn-created");
660
661- const apiHealthResponse = await fetch(`${baseUrl}/health`);
662- assert.equal(apiHealthResponse.status, 200);
663- const apiHealthPayload = await apiHealthResponse.json();
664- assert.equal(apiHealthPayload.ok, true);
665- assert.equal(apiHealthPayload.data.status, "ok");
666+ const pauseResponse = await fetch(`${baseUrl}/v1/system/pause`, {
667+ method: "POST",
668+ headers: {
669+ "content-type": "application/json"
670+ },
671+ body: JSON.stringify({
672+ requested_by: "integration_test",
673+ reason: "pause_for_verification"
674+ })
675+ });
676+ assert.equal(pauseResponse.status, 200);
677+ const pausePayload = await pauseResponse.json();
678+ assert.equal(pausePayload.data.mode, "paused");
679
680- const readyResponse = await fetch(`${baseUrl}/readyz`);
681- assert.equal(readyResponse.status, 200);
682- assert.equal(await readyResponse.text(), "ready\n");
683+ const pausedStateResponse = await fetch(`${baseUrl}/v1/system/state`);
684+ const pausedStatePayload = await pausedStateResponse.json();
685+ assert.equal(pausedStatePayload.data.mode, "paused");
686
687- const roleResponse = await fetch(`${baseUrl}/rolez`);
688- assert.equal(roleResponse.status, 200);
689- assert.equal(await roleResponse.text(), "leader\n");
690+ const describeResponse = await fetch(`${baseUrl}/describe`);
691+ assert.equal(describeResponse.status, 200);
692+ const describePayload = await describeResponse.json();
693+ assert.equal(describePayload.ok, true);
694+ assert.equal(describePayload.data.name, "baa-conductor-daemon");
695+ assert.equal(describePayload.data.codex.target_base_url, codexd.baseUrl);
696
697- const runtimeResponse = await fetch(`${baseUrl}/v1/runtime`);
698- assert.equal(runtimeResponse.status, 200);
699- const payload = await runtimeResponse.json();
700- assert.equal(payload.ok, true);
701- assert.equal(payload.data.identity, "mini-main@mini(primary)");
702- assert.equal(payload.data.controlApi.firefoxWsUrl, snapshot.controlApi.firefoxWsUrl);
703- assert.equal(payload.data.controlApi.localApiBase, baseUrl);
704- assert.equal(payload.data.runtime.started, true);
705+ const businessDescribeResponse = await fetch(`${baseUrl}/describe/business`);
706+ assert.equal(businessDescribeResponse.status, 200);
707+ const businessDescribePayload = await businessDescribeResponse.json();
708+ assert.equal(businessDescribePayload.data.surface, "business");
709+ assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/codex/u);
710
711- const systemStateResponse = await fetch(`${baseUrl}/v1/system/state`);
712- assert.equal(systemStateResponse.status, 200);
713- const systemStatePayload = await systemStateResponse.json();
714- assert.equal(systemStatePayload.ok, true);
715- assert.equal(systemStatePayload.data.holder_id, "mini-main");
716- assert.equal(systemStatePayload.data.mode, "running");
717-
718- const pauseResponse = await fetch(`${baseUrl}/v1/system/pause`, {
719- method: "POST",
720- headers: {
721- "content-type": "application/json"
722- },
723- body: JSON.stringify({
724- requested_by: "integration_test",
725- reason: "pause_for_verification"
726- })
727- });
728- assert.equal(pauseResponse.status, 200);
729- const pausePayload = await pauseResponse.json();
730- assert.equal(pausePayload.data.mode, "paused");
731-
732- const pausedStateResponse = await fetch(`${baseUrl}/v1/system/state`);
733- const pausedStatePayload = await pausedStateResponse.json();
734- assert.equal(pausedStatePayload.data.mode, "paused");
735-
736- const describeResponse = await fetch(`${baseUrl}/describe`);
737- assert.equal(describeResponse.status, 200);
738- const describePayload = await describeResponse.json();
739- assert.equal(describePayload.ok, true);
740- assert.equal(describePayload.data.name, "baa-conductor-daemon");
741-
742- const businessDescribeResponse = await fetch(`${baseUrl}/describe/business`);
743- assert.equal(businessDescribeResponse.status, 200);
744- const businessDescribePayload = await businessDescribeResponse.json();
745- assert.equal(businessDescribePayload.data.surface, "business");
746-
747- const controlDescribeResponse = await fetch(`${baseUrl}/describe/control`);
748- assert.equal(controlDescribeResponse.status, 200);
749- const controlDescribePayload = await controlDescribeResponse.json();
750- assert.equal(controlDescribePayload.data.surface, "control");
751- assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/exec/u);
752- assert.equal(controlDescribePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
753-
754- const unauthorizedExecResponse = await fetch(`${baseUrl}/v1/exec`, {
755- method: "POST",
756- headers: {
757- "content-type": "application/json"
758- },
759- body: JSON.stringify({
760- command: "printf 'runtime-host-op'",
761- cwd: hostOpsDir,
762- timeoutMs: 2000
763- })
764- });
765- assert.equal(unauthorizedExecResponse.status, 401);
766- const unauthorizedExecPayload = await unauthorizedExecResponse.json();
767- assert.equal(unauthorizedExecPayload.error, "unauthorized");
768-
769- const execResponse = await fetch(`${baseUrl}/v1/exec`, {
770- method: "POST",
771- headers: hostOpsHeaders,
772- body: JSON.stringify({
773- command: "printf 'runtime-host-op'",
774- cwd: hostOpsDir,
775- timeoutMs: 2000
776- })
777- });
778- assert.equal(execResponse.status, 200);
779- const execPayload = await execResponse.json();
780- assert.equal(execPayload.data.ok, true);
781- assert.equal(execPayload.data.result.stdout, "runtime-host-op");
782-
783- const writeResponse = await fetch(`${baseUrl}/v1/files/write`, {
784- method: "POST",
785- headers: hostOpsHeaders,
786- body: JSON.stringify({
787- path: "runtime/demo.txt",
788- cwd: hostOpsDir,
789- content: "hello from runtime host ops",
790- overwrite: false,
791- createParents: true
792- })
793- });
794- assert.equal(writeResponse.status, 200);
795- const writePayload = await writeResponse.json();
796- assert.equal(writePayload.data.ok, true);
797- assert.equal(writePayload.data.result.created, true);
798-
799- const duplicateWriteResponse = await fetch(`${baseUrl}/v1/files/write`, {
800- method: "POST",
801- headers: hostOpsHeaders,
802- body: JSON.stringify({
803- path: "runtime/demo.txt",
804- cwd: hostOpsDir,
805- content: "should not overwrite",
806- overwrite: false
807- })
808- });
809- assert.equal(duplicateWriteResponse.status, 200);
810- const duplicateWritePayload = await duplicateWriteResponse.json();
811- assert.equal(duplicateWritePayload.data.ok, false);
812- assert.equal(duplicateWritePayload.data.error.code, "FILE_ALREADY_EXISTS");
813-
814- const readResponse = await fetch(`${baseUrl}/v1/files/read`, {
815- method: "POST",
816- headers: hostOpsHeaders,
817- body: JSON.stringify({
818- path: "runtime/demo.txt",
819- cwd: hostOpsDir
820- })
821- });
822- assert.equal(readResponse.status, 200);
823- const readPayload = await readResponse.json();
824- assert.equal(readPayload.data.ok, true);
825- assert.equal(readPayload.data.result.content, "hello from runtime host ops");
826+ const controlDescribeResponse = await fetch(`${baseUrl}/describe/control`);
827+ assert.equal(controlDescribeResponse.status, 200);
828+ const controlDescribePayload = await controlDescribeResponse.json();
829+ assert.equal(controlDescribePayload.data.surface, "control");
830+ assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/exec/u);
831+ assert.equal(controlDescribePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
832
833- const stoppedSnapshot = await runtime.stop();
834- assert.equal(stoppedSnapshot.runtime.started, false);
835- rmSync(stateDir, {
836- force: true,
837- recursive: true
838- });
839- rmSync(hostOpsDir, {
840- force: true,
841- recursive: true
842- });
843+ const unauthorizedExecResponse = await fetch(`${baseUrl}/v1/exec`, {
844+ method: "POST",
845+ headers: {
846+ "content-type": "application/json"
847+ },
848+ body: JSON.stringify({
849+ command: "printf 'runtime-host-op'",
850+ cwd: hostOpsDir,
851+ timeoutMs: 2000
852+ })
853+ });
854+ assert.equal(unauthorizedExecResponse.status, 401);
855+ const unauthorizedExecPayload = await unauthorizedExecResponse.json();
856+ assert.equal(unauthorizedExecPayload.error, "unauthorized");
857+
858+ const execResponse = await fetch(`${baseUrl}/v1/exec`, {
859+ method: "POST",
860+ headers: hostOpsHeaders,
861+ body: JSON.stringify({
862+ command: "printf 'runtime-host-op'",
863+ cwd: hostOpsDir,
864+ timeoutMs: 2000
865+ })
866+ });
867+ assert.equal(execResponse.status, 200);
868+ const execPayload = await execResponse.json();
869+ assert.equal(execPayload.data.ok, true);
870+ assert.equal(execPayload.data.result.stdout, "runtime-host-op");
871+
872+ const writeResponse = await fetch(`${baseUrl}/v1/files/write`, {
873+ method: "POST",
874+ headers: hostOpsHeaders,
875+ body: JSON.stringify({
876+ path: "runtime/demo.txt",
877+ cwd: hostOpsDir,
878+ content: "hello from runtime host ops",
879+ overwrite: false,
880+ createParents: true
881+ })
882+ });
883+ assert.equal(writeResponse.status, 200);
884+ const writePayload = await writeResponse.json();
885+ assert.equal(writePayload.data.ok, true);
886+ assert.equal(writePayload.data.result.created, true);
887+
888+ const duplicateWriteResponse = await fetch(`${baseUrl}/v1/files/write`, {
889+ method: "POST",
890+ headers: hostOpsHeaders,
891+ body: JSON.stringify({
892+ path: "runtime/demo.txt",
893+ cwd: hostOpsDir,
894+ content: "should not overwrite",
895+ overwrite: false
896+ })
897+ });
898+ assert.equal(duplicateWriteResponse.status, 200);
899+ const duplicateWritePayload = await duplicateWriteResponse.json();
900+ assert.equal(duplicateWritePayload.data.ok, false);
901+ assert.equal(duplicateWritePayload.data.error.code, "FILE_ALREADY_EXISTS");
902+
903+ const readResponse = await fetch(`${baseUrl}/v1/files/read`, {
904+ method: "POST",
905+ headers: hostOpsHeaders,
906+ body: JSON.stringify({
907+ path: "runtime/demo.txt",
908+ cwd: hostOpsDir
909+ })
910+ });
911+ assert.equal(readResponse.status, 200);
912+ const readPayload = await readResponse.json();
913+ assert.equal(readPayload.data.ok, true);
914+ assert.equal(readPayload.data.result.content, "hello from runtime host ops");
915+
916+ const stoppedSnapshot = await runtime.stop();
917+ assert.equal(stoppedSnapshot.runtime.started, false);
918+ } finally {
919+ await codexd.stop();
920+ rmSync(stateDir, {
921+ force: true,
922+ recursive: true
923+ });
924+ rmSync(hostOpsDir, {
925+ force: true,
926+ recursive: true
927+ });
928+ }
929 });
930
931 test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status surfaces", async () => {
+34,
-0
1@@ -109,6 +109,7 @@ export interface ConductorRuntimePaths {
2 }
3
4 export interface ConductorRuntimeConfig extends ConductorConfig {
5+ codexdLocalApiBase?: string | null;
6 localApiAllowedHosts?: readonly string[] | string | null;
7 localApiBase?: string | null;
8 paths?: Partial<ConductorRuntimePaths>;
9@@ -120,6 +121,7 @@ export interface ResolvedConductorRuntimeConfig extends ConductorConfig {
10 leaseRenewIntervalMs: number;
11 localApiAllowedHosts: string[];
12 leaseTtlSec: number;
13+ codexdLocalApiBase: string | null;
14 localApiBase: string | null;
15 paths: ConductorRuntimePaths;
16 preferred: boolean;
17@@ -165,6 +167,9 @@ export interface ConductorRuntimeSnapshot {
18 hasSharedToken: boolean;
19 usesPlaceholderToken: boolean;
20 };
21+ codexd: {
22+ localApiBase: string | null;
23+ };
24 runtime: {
25 pid: number | null;
26 started: boolean;
27@@ -291,6 +296,7 @@ interface LocalApiListenConfig {
28 }
29
30 interface CliValueOverrides {
31+ codexdLocalApiBase?: string;
32 controlApiBase?: string;
33 heartbeatIntervalMs?: string;
34 host?: string;
35@@ -553,6 +559,8 @@ function normalizeIncomingRequestHeaders(
36 }
37
38 class ConductorLocalHttpServer {
39+ private readonly codexdLocalApiBase: string | null;
40+ private readonly fetchImpl: typeof fetch;
41 private readonly firefoxWebSocketServer: ConductorFirefoxWebSocketServer;
42 private readonly localApiBase: string;
43 private readonly now: () => number;
44@@ -567,10 +575,14 @@ class ConductorLocalHttpServer {
45 localApiBase: string,
46 repository: ControlPlaneRepository,
47 snapshotLoader: () => ConductorRuntimeSnapshot,
48+ codexdLocalApiBase: string | null,
49+ fetchImpl: typeof fetch,
50 sharedToken: string | null,
51 version: string | null,
52 now: () => number
53 ) {
54+ this.codexdLocalApiBase = codexdLocalApiBase;
55+ this.fetchImpl = fetchImpl;
56 this.localApiBase = localApiBase;
57 this.now = now;
58 this.repository = repository;
59@@ -610,6 +622,8 @@ class ConductorLocalHttpServer {
60 path: request.url ?? "/"
61 },
62 {
63+ codexdLocalApiBase: this.codexdLocalApiBase,
64+ fetchImpl: this.fetchImpl,
65 repository: this.repository,
66 sharedToken: this.sharedToken,
67 snapshotLoader: this.snapshotLoader,
68@@ -1396,6 +1410,7 @@ export function resolveConductorRuntimeConfig(
69 host,
70 role: parseConductorRole("Conductor role", config.role),
71 controlApiBase: normalizeBaseUrl(controlApiBase),
72+ codexdLocalApiBase: resolveLocalApiBase(config.codexdLocalApiBase),
73 heartbeatIntervalMs,
74 leaseRenewIntervalMs,
75 localApiAllowedHosts,
76@@ -1475,6 +1490,9 @@ function resolveRuntimeConfigFromSources(
77 overrides.renewFailureThreshold ?? env.BAA_CONDUCTOR_RENEW_FAILURE_THRESHOLD,
78 { minimum: 1 }
79 ),
80+ codexdLocalApiBase: normalizeOptionalString(
81+ overrides.codexdLocalApiBase ?? env.BAA_CODEXD_LOCAL_API_BASE
82+ ),
83 localApiAllowedHosts: overrides.localApiAllowedHosts ?? env.BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS,
84 localApiBase: normalizeOptionalString(overrides.localApiBase ?? env.BAA_CONDUCTOR_LOCAL_API),
85 sharedToken: normalizeOptionalString(overrides.sharedToken ?? env.BAA_SHARED_TOKEN),
86@@ -1546,6 +1564,10 @@ export function parseConductorCliRequest(
87 overrides.controlApiBase = readOptionValue(tokens, token, index);
88 index += 1;
89 break;
90+ case "--codexd-local-api":
91+ overrides.codexdLocalApiBase = readOptionValue(tokens, token, index);
92+ index += 1;
93+ break;
94 case "--local-api":
95 overrides.localApiBase = readOptionValue(tokens, token, index);
96 index += 1;
97@@ -1649,6 +1671,10 @@ function buildRuntimeWarnings(config: ResolvedConductorRuntimeConfig): string[]
98 warnings.push("BAA_CONDUCTOR_LOCAL_API is not configured; only the in-process snapshot interface is available.");
99 }
100
101+ if (config.codexdLocalApiBase == null) {
102+ warnings.push("BAA_CODEXD_LOCAL_API_BASE is not configured; /v1/codex routes will stay unavailable.");
103+ }
104+
105 if (config.leaseRenewIntervalMs >= config.leaseTtlSec * 1_000) {
106 warnings.push("lease renew interval is >= lease TTL; leader renewals may race with lease expiry.");
107 }
108@@ -1677,6 +1703,7 @@ function formatConfigText(config: ResolvedConductorRuntimeConfig): string {
109 return [
110 `identity: ${config.nodeId}@${config.host}(${config.role})`,
111 `control_api_base: ${config.controlApiBase}`,
112+ `codexd_local_api_base: ${config.codexdLocalApiBase ?? "not-configured"}`,
113 `local_api_base: ${config.localApiBase ?? "not-configured"}`,
114 `firefox_ws_url: ${buildFirefoxWebSocketUrl(config.localApiBase) ?? "not-configured"}`,
115 `local_api_allowed_hosts: ${config.localApiAllowedHosts.join(",") || "loopback-only"}`,
116@@ -1721,6 +1748,7 @@ function getUsageText(): string {
117 " --host <host>",
118 " --role <primary|standby>",
119 " --control-api-base <url>",
120+ " --codexd-local-api <url>",
121 " --local-api <url>",
122 " --shared-token <token>",
123 " --priority <integer>",
124@@ -1744,6 +1772,7 @@ function getUsageText(): string {
125 " BAA_CONDUCTOR_HOST",
126 " BAA_CONDUCTOR_ROLE",
127 " BAA_CONTROL_API_BASE",
128+ " BAA_CODEXD_LOCAL_API_BASE",
129 " BAA_CONDUCTOR_LOCAL_API",
130 " BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS",
131 " BAA_SHARED_TOKEN",
132@@ -1796,6 +1825,8 @@ export class ConductorRuntime {
133 this.config.localApiBase,
134 this.localControlPlane.repository,
135 () => this.getRuntimeSnapshot(),
136+ this.config.codexdLocalApiBase,
137+ options.fetchImpl ?? globalThis.fetch,
138 this.config.sharedToken,
139 this.config.version,
140 this.now
141@@ -1849,6 +1880,9 @@ export class ConductorRuntime {
142 hasSharedToken: this.config.sharedToken != null,
143 usesPlaceholderToken: usesPlaceholderToken(this.config.sharedToken)
144 },
145+ codexd: {
146+ localApiBase: this.config.codexdLocalApiBase
147+ },
148 runtime: {
149 pid: getProcessLike()?.pid ?? null,
150 started: this.started,
+502,
-15
1@@ -36,6 +36,14 @@ const DEFAULT_LOG_LIMIT = 200;
2 const MAX_LIST_LIMIT = 100;
3 const MAX_LOG_LIMIT = 500;
4 const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
5+const CODEXD_LOCAL_API_ENV = "BAA_CODEXD_LOCAL_API_BASE";
6+const CODEX_ROUTE_IDS = new Set([
7+ "codex.status",
8+ "codex.sessions.list",
9+ "codex.sessions.read",
10+ "codex.sessions.create",
11+ "codex.turn.create"
12+]);
13 const HOST_OPERATIONS_ROUTE_IDS = new Set(["host.exec", "host.files.read", "host.files.write"]);
14 const HOST_OPERATIONS_AUTH_HEADER = "Authorization: Bearer <BAA_SHARED_TOKEN>";
15 const HOST_OPERATIONS_WWW_AUTHENTICATE = 'Bearer realm="baa-conductor-host-ops"';
16@@ -63,7 +71,21 @@ interface LocalApiRouteMatch {
17 route: LocalApiRouteDefinition;
18 }
19
20+type UpstreamSuccessEnvelope = JsonObject & {
21+ data: JsonValue;
22+ ok: true;
23+};
24+
25+type UpstreamErrorEnvelope = JsonObject & {
26+ details?: JsonValue;
27+ error: string;
28+ message: string;
29+ ok: false;
30+};
31+
32 interface LocalApiRequestContext {
33+ codexdLocalApiBase: string | null;
34+ fetchImpl: typeof fetch;
35 now: () => number;
36 params: Record<string, string>;
37 repository: ControlPlaneRepository | null;
38@@ -75,6 +97,9 @@ interface LocalApiRequestContext {
39 }
40
41 export interface ConductorRuntimeApiSnapshot {
42+ codexd: {
43+ localApiBase: string | null;
44+ };
45 controlApi: {
46 baseUrl: string;
47 firefoxWsUrl?: string | null;
48@@ -103,6 +128,8 @@ export interface ConductorRuntimeApiSnapshot {
49 }
50
51 export interface ConductorLocalApiContext {
52+ codexdLocalApiBase?: string | null;
53+ fetchImpl?: typeof fetch;
54 now?: () => number;
55 repository: ControlPlaneRepository | null;
56 sharedToken?: string | null;
57@@ -197,6 +224,41 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
58 pathPattern: "/v1/capabilities",
59 summary: "读取能力发现摘要"
60 },
61+ {
62+ id: "codex.status",
63+ kind: "read",
64+ method: "GET",
65+ pathPattern: "/v1/codex",
66+ summary: "读取独立 codexd 代理状态与会话能力摘要"
67+ },
68+ {
69+ id: "codex.sessions.list",
70+ kind: "read",
71+ method: "GET",
72+ pathPattern: "/v1/codex/sessions",
73+ summary: "列出独立 codexd 当前会话"
74+ },
75+ {
76+ id: "codex.sessions.read",
77+ kind: "read",
78+ method: "GET",
79+ pathPattern: "/v1/codex/sessions/:session_id",
80+ summary: "读取独立 codexd 的单个会话"
81+ },
82+ {
83+ id: "codex.sessions.create",
84+ kind: "write",
85+ method: "POST",
86+ pathPattern: "/v1/codex/sessions",
87+ summary: "通过 conductor 代理创建独立 codexd 会话"
88+ },
89+ {
90+ id: "codex.turn.create",
91+ kind: "write",
92+ method: "POST",
93+ pathPattern: "/v1/codex/turn",
94+ summary: "向独立 codexd 会话提交 turn"
95+ },
96 {
97 id: "system.state",
98 kind: "read",
99@@ -276,6 +338,7 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
100 },
101 {
102 id: "runs.list",
103+ exposeInDescribe: false,
104 kind: "read",
105 method: "GET",
106 pathPattern: "/v1/runs",
107@@ -283,6 +346,7 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
108 },
109 {
110 id: "runs.read",
111+ exposeInDescribe: false,
112 kind: "read",
113 method: "GET",
114 pathPattern: "/v1/runs/:run_id",
115@@ -455,6 +519,82 @@ function buildFileWriteOperationRequest(body: JsonObject): FileWriteOperationReq
116 };
117 }
118
119+function buildCodexSessionCreateRequest(body: JsonObject): JsonObject {
120+ return compactJsonObject({
121+ approvalPolicy: readBodyField(body, "approvalPolicy", "approval_policy"),
122+ baseInstructions: readBodyField(body, "baseInstructions", "base_instructions"),
123+ config: readBodyField(body, "config"),
124+ cwd: readBodyField(body, "cwd"),
125+ developerInstructions: readBodyField(body, "developerInstructions", "developer_instructions"),
126+ ephemeral: readBodyField(body, "ephemeral"),
127+ metadata: readBodyField(body, "metadata"),
128+ model: readBodyField(body, "model"),
129+ modelProvider: readBodyField(body, "modelProvider", "model_provider"),
130+ personality: readBodyField(body, "personality"),
131+ purpose: readBodyField(body, "purpose"),
132+ sandbox: readBodyField(body, "sandbox"),
133+ serviceTier: readBodyField(body, "serviceTier", "service_tier"),
134+ threadId: readBodyField(body, "threadId", "thread_id")
135+ });
136+}
137+
138+function buildCodexTurnCreateRequest(body: JsonObject): JsonObject {
139+ return compactJsonObject({
140+ approvalPolicy: readBodyField(body, "approvalPolicy", "approval_policy"),
141+ collaborationMode: readBodyField(body, "collaborationMode", "collaboration_mode"),
142+ cwd: readBodyField(body, "cwd"),
143+ effort: readBodyField(body, "effort"),
144+ expectedTurnId: readBodyField(body, "expectedTurnId", "expected_turn_id"),
145+ input: readBodyField(body, "input", "prompt"),
146+ model: readBodyField(body, "model"),
147+ outputSchema: readBodyField(body, "outputSchema", "output_schema"),
148+ personality: readBodyField(body, "personality"),
149+ sandboxPolicy: readBodyField(body, "sandboxPolicy", "sandbox_policy"),
150+ serviceTier: readBodyField(body, "serviceTier", "service_tier"),
151+ sessionId: readBodyField(body, "sessionId", "session_id"),
152+ summary: readBodyField(body, "summary")
153+ });
154+}
155+
156+function compactJsonObject(record: Record<string, JsonValue | undefined>): JsonObject {
157+ const result: JsonObject = {};
158+
159+ for (const [key, value] of Object.entries(record)) {
160+ if (value !== undefined) {
161+ result[key] = value;
162+ }
163+ }
164+
165+ return result;
166+}
167+
168+function isUpstreamSuccessEnvelope(value: JsonValue | null): value is UpstreamSuccessEnvelope {
169+ return isJsonObject(value) && value.ok === true && "data" in value;
170+}
171+
172+function isUpstreamErrorEnvelope(value: JsonValue | null): value is UpstreamErrorEnvelope {
173+ return (
174+ isJsonObject(value)
175+ && value.ok === false
176+ && typeof value.error === "string"
177+ && typeof value.message === "string"
178+ );
179+}
180+
181+function isCodexRoute(route: LocalApiRouteDefinition): boolean {
182+ return CODEX_ROUTE_IDS.has(route.id);
183+}
184+
185+function requireRouteDefinition(id: string): LocalApiRouteDefinition {
186+ const route = LOCAL_API_ROUTES.find((entry) => entry.id === id);
187+
188+ if (!route) {
189+ throw new Error(`Unknown local route definition "${id}".`);
190+ }
191+
192+ return route;
193+}
194+
195 function readPositiveIntegerQuery(
196 url: URL,
197 fieldName: string,
198@@ -667,6 +807,256 @@ export async function buildSystemStateData(repository: ControlPlaneRepository):
199 };
200 }
201
202+function asJsonObject(value: JsonValue | null | undefined): JsonObject | null {
203+ return isJsonObject(value ?? null) ? (value as JsonObject) : null;
204+}
205+
206+function readJsonObjectField(record: JsonObject | null, fieldName: string): JsonObject | null {
207+ if (record == null) {
208+ return null;
209+ }
210+
211+ return asJsonObject(record[fieldName]);
212+}
213+
214+function readJsonArrayField(record: JsonObject | null, fieldName: string): JsonValue[] {
215+ if (record == null) {
216+ return [];
217+ }
218+
219+ const value = record[fieldName];
220+ return Array.isArray(value) ? value : [];
221+}
222+
223+function readStringValue(record: JsonObject | null, fieldName: string): string | null {
224+ if (record == null) {
225+ return null;
226+ }
227+
228+ const value = record[fieldName];
229+ return typeof value === "string" ? value : null;
230+}
231+
232+function readBooleanValue(record: JsonObject | null, fieldName: string): boolean | null {
233+ if (record == null) {
234+ return null;
235+ }
236+
237+ const value = record[fieldName];
238+ return typeof value === "boolean" ? value : null;
239+}
240+
241+function readNumberValue(record: JsonObject | null, fieldName: string): number | null {
242+ if (record == null) {
243+ return null;
244+ }
245+
246+ const value = record[fieldName];
247+ return typeof value === "number" && Number.isFinite(value) ? value : null;
248+}
249+
250+function buildCodexRouteCatalog(): JsonObject[] {
251+ return LOCAL_API_ROUTES.filter((route) => isCodexRoute(route)).map((route) => describeRoute(route));
252+}
253+
254+function buildCodexProxyNotes(snapshot: ConductorRuntimeApiSnapshot): string[] {
255+ if (snapshot.codexd.localApiBase == null) {
256+ return [
257+ "Codex routes stay unavailable until independent codexd is configured.",
258+ `Set ${CODEXD_LOCAL_API_ENV} to the codexd local HTTP base URL before using /v1/codex.`
259+ ];
260+ }
261+
262+ return [
263+ "All /v1/codex routes proxy the independent codexd daemon over local HTTP.",
264+ "This surface is session-oriented: status, session list/read/create, and turn create."
265+ ];
266+}
267+
268+function buildCodexProxyData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
269+ return {
270+ auth_mode: "local_network_only",
271+ backend: "independent_codexd",
272+ enabled: snapshot.codexd.localApiBase != null,
273+ route_prefix: "/v1/codex",
274+ routes: buildCodexRouteCatalog(),
275+ target_base_url: snapshot.codexd.localApiBase,
276+ transport: "local_http",
277+ notes: buildCodexProxyNotes(snapshot)
278+ };
279+}
280+
281+function buildCodexStatusData(data: JsonValue, proxyTargetBaseUrl: string): JsonObject {
282+ const root = asJsonObject(data);
283+ const service = readJsonObjectField(root, "service");
284+ const snapshot = readJsonObjectField(root, "snapshot");
285+ const identity = readJsonObjectField(snapshot, "identity");
286+ const daemon = readJsonObjectField(snapshot, "daemon");
287+ const child = readJsonObjectField(daemon, "child");
288+ const sessionRegistry = readJsonObjectField(snapshot, "sessionRegistry");
289+ const recentEvents = readJsonObjectField(snapshot, "recentEvents");
290+ const sessions = readJsonArrayField(sessionRegistry, "sessions");
291+ const events = readJsonArrayField(recentEvents, "events");
292+
293+ return {
294+ backend: "independent_codexd",
295+ daemon: {
296+ daemon_id: readStringValue(identity, "daemonId"),
297+ node_id: readStringValue(identity, "nodeId"),
298+ version: readStringValue(identity, "version"),
299+ started: readBooleanValue(daemon, "started"),
300+ started_at: readStringValue(daemon, "startedAt"),
301+ updated_at: readStringValue(daemon, "updatedAt"),
302+ child: {
303+ endpoint: readStringValue(child, "endpoint"),
304+ last_error: readStringValue(child, "lastError"),
305+ pid: readNumberValue(child, "pid"),
306+ status: readStringValue(child, "status"),
307+ strategy: readStringValue(child, "strategy")
308+ }
309+ },
310+ proxy: {
311+ route_prefix: "/v1/codex",
312+ target_base_url: proxyTargetBaseUrl,
313+ transport: "local_http"
314+ },
315+ recent_events: {
316+ count: events.length,
317+ updated_at: readStringValue(recentEvents, "updatedAt")
318+ },
319+ routes: buildCodexRouteCatalog(),
320+ service: {
321+ event_stream_url: readStringValue(service, "eventStreamUrl"),
322+ listening: readBooleanValue(service, "listening"),
323+ resolved_base_url: readStringValue(service, "resolvedBaseUrl"),
324+ websocket_clients: readNumberValue(service, "websocketClients")
325+ },
326+ sessions: {
327+ active_count: sessions.filter((entry) => readStringValue(asJsonObject(entry), "status") === "active").length,
328+ count: sessions.length,
329+ updated_at: readStringValue(sessionRegistry, "updatedAt")
330+ },
331+ surface: ["status", "sessions", "turn"],
332+ notes: [
333+ "This conductor route proxies the independent codexd daemon.",
334+ "Only session/status/turn operations are exposed on this surface."
335+ ]
336+ };
337+}
338+
339+async function requestCodexd(
340+ context: LocalApiRequestContext,
341+ input: {
342+ body?: JsonObject;
343+ method: LocalApiRouteMethod;
344+ path: string;
345+ }
346+): Promise<{ data: JsonValue; status: number }> {
347+ const codexdLocalApiBase =
348+ normalizeOptionalString(context.codexdLocalApiBase) ?? context.snapshotLoader().codexd.localApiBase;
349+
350+ if (codexdLocalApiBase == null) {
351+ throw new LocalApiHttpError(
352+ 503,
353+ "codexd_not_configured",
354+ "Independent codexd local API is not configured for /v1/codex routes.",
355+ {
356+ env_var: CODEXD_LOCAL_API_ENV
357+ }
358+ );
359+ }
360+
361+ let response: Response;
362+
363+ try {
364+ response = await context.fetchImpl(`${codexdLocalApiBase}${input.path}`, {
365+ method: input.method,
366+ headers: input.body
367+ ? {
368+ accept: "application/json",
369+ "content-type": "application/json"
370+ }
371+ : {
372+ accept: "application/json"
373+ },
374+ body: input.body ? JSON.stringify(input.body) : undefined
375+ });
376+ } catch (error) {
377+ throw new LocalApiHttpError(
378+ 503,
379+ "codexd_unavailable",
380+ `Independent codexd is unavailable at ${codexdLocalApiBase}.`,
381+ {
382+ cause: error instanceof Error ? error.message : String(error),
383+ target_base_url: codexdLocalApiBase,
384+ upstream_path: input.path
385+ }
386+ );
387+ }
388+
389+ const rawBody = await response.text();
390+ let parsedBody: JsonValue | null = null;
391+
392+ if (rawBody !== "") {
393+ try {
394+ parsedBody = JSON.parse(rawBody) as JsonValue;
395+ } catch {
396+ throw new LocalApiHttpError(
397+ 502,
398+ "codexd_invalid_response",
399+ `Independent codexd returned invalid JSON for ${input.method} ${input.path}.`,
400+ {
401+ target_base_url: codexdLocalApiBase,
402+ upstream_path: input.path
403+ }
404+ );
405+ }
406+ }
407+
408+ if (!response.ok) {
409+ if (isUpstreamErrorEnvelope(parsedBody)) {
410+ throw new LocalApiHttpError(
411+ response.status,
412+ response.status === 404 ? "not_found" : parsedBody.error,
413+ parsedBody.message,
414+ compactJsonObject({
415+ target_base_url: codexdLocalApiBase,
416+ upstream_details: parsedBody.details,
417+ upstream_error: parsedBody.error,
418+ upstream_path: input.path
419+ })
420+ );
421+ }
422+
423+ throw new LocalApiHttpError(
424+ response.status,
425+ response.status >= 500 ? "codexd_unavailable" : "codexd_proxy_error",
426+ `Independent codexd returned HTTP ${response.status} for ${input.method} ${input.path}.`,
427+ {
428+ target_base_url: codexdLocalApiBase,
429+ upstream_path: input.path
430+ }
431+ );
432+ }
433+
434+ if (!isUpstreamSuccessEnvelope(parsedBody)) {
435+ throw new LocalApiHttpError(
436+ 502,
437+ "codexd_invalid_response",
438+ `Independent codexd returned an unexpected payload for ${input.method} ${input.path}.`,
439+ {
440+ target_base_url: codexdLocalApiBase,
441+ upstream_path: input.path
442+ }
443+ );
444+ }
445+
446+ return {
447+ data: parsedBody.data,
448+ status: response.status
449+ };
450+}
451+
452 function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
453 return {
454 auth_mode: "local_network_only",
455@@ -929,12 +1319,15 @@ function routeBelongsToSurface(
456 "service.health",
457 "service.version",
458 "system.capabilities",
459+ "codex.status",
460+ "codex.sessions.list",
461+ "codex.sessions.read",
462+ "codex.sessions.create",
463+ "codex.turn.create",
464 "controllers.list",
465 "tasks.list",
466 "tasks.read",
467- "tasks.logs.read",
468- "runs.list",
469- "runs.read"
470+ "tasks.logs.read"
471 ].includes(route.id);
472 }
473
474@@ -976,13 +1369,15 @@ function buildCapabilitiesData(
475 "GET /describe",
476 "GET /v1/capabilities",
477 "GET /v1/system/state",
478- "GET /v1/tasks or /v1/runs",
479+ "GET /v1/tasks or /v1/codex",
480+ "Use /v1/codex/* for interactive Codex session and turn work",
481 "GET /describe/control if local shell/file access is needed",
482 "Use POST system routes or host operations only when a write/exec is intended"
483 ],
484 read_endpoints: exposedRoutes.filter((route) => route.kind === "read").map(describeRoute),
485 write_endpoints: exposedRoutes.filter((route) => route.kind === "write").map(describeRoute),
486 diagnostics: LOCAL_API_ROUTES.filter((route) => route.kind === "probe").map(describeRoute),
487+ codex: buildCodexProxyData(snapshot),
488 runtime: {
489 identity: snapshot.identity,
490 lease_state: snapshot.daemon.leaseState,
491@@ -1041,10 +1436,11 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
492 auth: buildHttpAuthData(snapshot),
493 system,
494 websocket: buildFirefoxWebSocketData(snapshot),
495+ codex: buildCodexProxyData(snapshot),
496 describe_endpoints: {
497 business: {
498 path: "/describe/business",
499- summary: "业务查询入口;适合 CLI AI、网页版 AI、手机网页 AI 先读。"
500+ summary: "业务查询和 Codex session 入口;适合 CLI AI、网页版 AI、手机网页 AI 先读。"
501 },
502 control: {
503 path: "/describe/control",
504@@ -1087,16 +1483,19 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
505 title: "Inspect the narrower capability surface if needed",
506 method: "GET",
507 path: "/v1/capabilities",
508- curl: buildCurlExample(
509- origin,
510- LOCAL_API_ROUTES.find((route) => route.id === "system.capabilities")!
511- )
512+ curl: buildCurlExample(origin, requireRouteDefinition("system.capabilities"))
513+ },
514+ {
515+ title: "Inspect the codex proxy surface",
516+ method: "GET",
517+ path: "/v1/codex",
518+ curl: buildCurlExample(origin, requireRouteDefinition("codex.status"))
519 },
520 {
521 title: "Read the current automation state",
522 method: "GET",
523 path: "/v1/system/state",
524- curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "system.state")!)
525+ curl: buildCurlExample(origin, requireRouteDefinition("system.state"))
526 },
527 {
528 title: "Pause local automation explicitly",
529@@ -1128,6 +1527,7 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
530 ],
531 notes: [
532 "AI callers should prefer /describe/business for business queries and /describe/control for control actions.",
533+ "All /v1/codex routes proxy the independent codexd daemon; this process does not host Codex sessions itself.",
534 "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
535 "These routes read and mutate the mini node's local truth source directly.",
536 "GET /healthz, /readyz, /rolez and /v1/runtime remain available as low-level diagnostics.",
537@@ -1164,28 +1564,39 @@ async function handleScopedDescribeRead(
538 recommended_flow: [
539 "GET /describe/business",
540 "Optionally GET /v1/capabilities",
541- "Use read-only routes such as /v1/controllers, /v1/tasks and /v1/runs",
542+ "Use business routes such as /v1/controllers, /v1/tasks and /v1/codex",
543 "Use /describe/control if a local shell or file operation is intended"
544 ],
545 system,
546 websocket: buildFirefoxWebSocketData(snapshot),
547+ codex: buildCodexProxyData(snapshot),
548 endpoints: routes.map(describeRoute),
549 examples: [
550 {
551 title: "List recent tasks",
552 method: "GET",
553 path: "/v1/tasks?limit=5",
554- curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "tasks.list")!)
555+ curl: buildCurlExample(origin, requireRouteDefinition("tasks.list"))
556 },
557 {
558- title: "List recent runs",
559+ title: "Inspect the codex proxy status",
560 method: "GET",
561- path: "/v1/runs?limit=5",
562- curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "runs.list")!)
563+ path: "/v1/codex",
564+ curl: buildCurlExample(origin, requireRouteDefinition("codex.status"))
565+ },
566+ {
567+ title: "Create a codex session",
568+ method: "POST",
569+ path: "/v1/codex/sessions",
570+ curl: buildCurlExample(origin, requireRouteDefinition("codex.sessions.create"), {
571+ cwd: "/Users/george/code/baa-conductor",
572+ purpose: "duplex"
573+ })
574 }
575 ],
576 notes: [
577 "This surface is intended to be enough for business-query discovery without reading external docs.",
578+ "All /v1/codex routes proxy the independent codexd daemon instead of an in-process bridge.",
579 "If you pivot to /describe/control for /v1/exec or /v1/files/*, those host-ops routes require Authorization: Bearer <BAA_SHARED_TOKEN>.",
580 "Control actions and host-level exec/file operations are intentionally excluded; use /describe/control."
581 ]
582@@ -1215,6 +1626,7 @@ async function handleScopedDescribeRead(
583 system,
584 websocket: buildFirefoxWebSocketData(snapshot),
585 auth: buildHttpAuthData(snapshot),
586+ codex: buildCodexProxyData(snapshot),
587 host_operations: buildHostOperationsData(origin, snapshot),
588 endpoints: routes.map(describeRoute),
589 examples: [
590@@ -1273,6 +1685,7 @@ async function handleScopedDescribeRead(
591 ],
592 notes: [
593 "This surface is intended to be enough for control discovery without reading external docs.",
594+ "The interactive Codex surface is proxied to independent codexd; inspect /v1/codex or /describe/business for those routes.",
595 "Business queries such as tasks and runs are intentionally excluded; use /describe/business.",
596 "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
597 "Host operations return the structured host-ops union inside the outer conductor HTTP envelope."
598@@ -1316,6 +1729,7 @@ async function handleCapabilitiesRead(
599 system: await buildSystemStateData(repository),
600 notes: [
601 "Read routes are safe for discovery and inspection.",
602+ "All /v1/codex routes proxy the independent codexd daemon over local HTTP.",
603 "POST /v1/system/* writes the local automation mode immediately.",
604 "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN> and return 401 JSON on missing or wrong tokens.",
605 "POST /v1/exec and POST /v1/files/* return the structured host-ops union in data."
606@@ -1506,6 +1920,66 @@ async function handleHostFileWrite(context: LocalApiRequestContext): Promise<Con
607 );
608 }
609
610+async function handleCodexStatusRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
611+ const result = await requestCodexd(context, {
612+ method: "GET",
613+ path: "/v1/codexd/status"
614+ });
615+
616+ return buildSuccessEnvelope(
617+ context.requestId,
618+ 200,
619+ buildCodexStatusData(
620+ result.data,
621+ normalizeOptionalString(context.codexdLocalApiBase) ?? context.snapshotLoader().codexd.localApiBase ?? ""
622+ )
623+ );
624+}
625+
626+async function handleCodexSessionsList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
627+ const result = await requestCodexd(context, {
628+ method: "GET",
629+ path: "/v1/codexd/sessions"
630+ });
631+
632+ return buildSuccessEnvelope(context.requestId, result.status, result.data);
633+}
634+
635+async function handleCodexSessionRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
636+ const sessionId = context.params.session_id;
637+
638+ if (!sessionId) {
639+ throw new LocalApiHttpError(400, "invalid_request", "Route parameter \"session_id\" is required.");
640+ }
641+
642+ const result = await requestCodexd(context, {
643+ method: "GET",
644+ path: `/v1/codexd/sessions/${encodeURIComponent(sessionId)}`
645+ });
646+
647+ return buildSuccessEnvelope(context.requestId, result.status, result.data);
648+}
649+
650+async function handleCodexSessionCreate(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
651+ const result = await requestCodexd(context, {
652+ body: buildCodexSessionCreateRequest(readBodyObject(context.request, true)),
653+ method: "POST",
654+ path: "/v1/codexd/sessions"
655+ });
656+
657+ return buildSuccessEnvelope(context.requestId, result.status, result.data);
658+}
659+
660+async function handleCodexTurnCreate(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
661+ const result = await requestCodexd(context, {
662+ body: buildCodexTurnCreateRequest(readBodyObject(context.request, false)),
663+ method: "POST",
664+ path: "/v1/codexd/turn"
665+ });
666+
667+ return buildSuccessEnvelope(context.requestId, result.status, result.data);
668+}
669+
670 async function handleControllersList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
671 const repository = requireRepository(context.repository);
672 const limit = readPositiveIntegerQuery(context.url, "limit", DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
673@@ -1649,6 +2123,16 @@ async function dispatchBusinessRoute(
674 return handleVersionRead(context.requestId, version);
675 case "system.capabilities":
676 return handleCapabilitiesRead(context, version);
677+ case "codex.status":
678+ return handleCodexStatusRead(context);
679+ case "codex.sessions.list":
680+ return handleCodexSessionsList(context);
681+ case "codex.sessions.read":
682+ return handleCodexSessionRead(context);
683+ case "codex.sessions.create":
684+ return handleCodexSessionCreate(context);
685+ case "codex.turn.create":
686+ return handleCodexTurnCreate(context);
687 case "system.state":
688 return handleSystemStateRead(context);
689 case "system.pause":
690@@ -1811,6 +2295,9 @@ export async function handleConductorHttpRequest(
691 return await dispatchRoute(
692 matchedRoute,
693 {
694+ codexdLocalApiBase:
695+ normalizeOptionalString(context.codexdLocalApiBase) ?? context.snapshotLoader().codexd.localApiBase,
696+ fetchImpl: context.fetchImpl ?? globalThis.fetch,
697 now: context.now ?? (() => Math.floor(Date.now() / 1000)),
698 params: matchedRoute.params,
699 repository: context.repository,
+32,
-4
1@@ -28,7 +28,8 @@
2 | 服务 | 地址 | 说明 |
3 | --- | --- | --- |
4 | conductor public host | `https://conductor.makefile.so` | 唯一公网入口;VPS Nginx 回源到同一个 `conductor-daemon` local-api |
5-| conductor-daemon local-api | `BAA_CONDUCTOR_LOCAL_API`,默认可用值如 `http://127.0.0.1:4317` | 本地真相源;承接 describe/health/version/capabilities/system/controllers/tasks/runs/host-ops |
6+| conductor-daemon local-api | `BAA_CONDUCTOR_LOCAL_API`,默认可用值如 `http://127.0.0.1:4317` | 本地真相源;承接 describe/health/version/capabilities/system/controllers/tasks/codex/host-ops |
7+| codexd local-api | `BAA_CODEXD_LOCAL_API_BASE`,默认可用值如 `http://127.0.0.1:4323` | 独立 `codexd` 本地服务;`conductor-daemon` 的 `/v1/codex/*` 只代理到这里 |
8 | conductor-daemon local-firefox-ws | 由 `BAA_CONDUCTOR_LOCAL_API` 派生,例如 `ws://127.0.0.1:4317/ws/firefox` | 本地 Firefox 插件双向 bridge;复用同一个 listener,不单独开公网端口 |
9 | status-api local view | `http://127.0.0.1:4318` | 本地只读状态 JSON 和 HTML 视图,不承担公网入口角色 |
10
11@@ -39,7 +40,7 @@
12 1. `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/business` 或 `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/control`
13 2. 如有需要,再看 `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/capabilities`
14 3. 如果是控制动作,再看 `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/system/state`
15-4. 按需查看 `controllers`、`tasks`、`runs`
16+4. 按需查看 `controllers`、`tasks`、`codex`
17 5. 如果要做本机 shell / 文件操作,先读 `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/control` 返回里的 `host_operations`,并准备 `Authorization: Bearer <BAA_SHARED_TOKEN>`
18 6. 只有在明确需要写操作时,再调用 `pause` / `resume` / `drain` 或 `host-ops`
19 7. 只有在明确需要浏览器双向通讯时,再手动连接 `/ws/firefox`
20@@ -76,6 +77,23 @@
21 - 成功时直接返回最新 system state,而不是只回一个 ack
22 - 这样 HTTP 客户端和 WS `action_request` 都能复用同一份状态合同
23
24+### Codex 代理接口
25+
26+这些路由不是 `conductor-daemon` 内嵌 bridge,而是固定代理到独立 `codexd`:
27+
28+| 方法 | 路径 | 说明 |
29+| --- | --- | --- |
30+| `GET` | `/v1/codex` | 读取 `codexd` 代理状态、session 摘要和可用路由 |
31+| `GET` | `/v1/codex/sessions` | 列出当前 Codex sessions |
32+| `GET` | `/v1/codex/sessions/:session_id` | 读取单个 Codex session |
33+| `POST` | `/v1/codex/sessions` | 创建或恢复一个 Codex session |
34+| `POST` | `/v1/codex/turn` | 向现有 session 提交一轮 turn |
35+
36+当前正式口径只保留 session / turn / status:
37+
38+- `conductor-daemon` 不对外代理 `/v1/codex/runs*`
39+- 不把 `codex exec` 当作正式产品能力
40+
41 ### 本机 Host Ops 接口
42
43 | 方法 | 路径 | 说明 |
44@@ -103,8 +121,6 @@ host-ops 约定:
45 | `GET` | `/v1/tasks?status=queued&limit=20` | 最近 task 摘要,可按 `status` 过滤 |
46 | `GET` | `/v1/tasks/:task_id` | 单个 task 详情 |
47 | `GET` | `/v1/tasks/:task_id/logs?limit=200` | task 关联日志,可选 `run_id` 过滤 |
48-| `GET` | `/v1/runs?limit=20` | 最近 run 摘要 |
49-| `GET` | `/v1/runs/:run_id` | 单个 run 详情 |
50
51 ### 诊断接口
52
53@@ -176,6 +192,18 @@ LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
54 curl "${LOCAL_API_BASE}/v1/tasks?limit=5"
55 ```
56
57+```bash
58+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
59+curl "${LOCAL_API_BASE}/v1/codex"
60+```
61+
62+```bash
63+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
64+curl -X POST "${LOCAL_API_BASE}/v1/codex/sessions" \
65+ -H 'Content-Type: application/json' \
66+ -d '{"cwd":"/Users/george/code/baa-conductor","purpose":"duplex"}'
67+```
68+
69 ```bash
70 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
71 curl -X POST "${LOCAL_API_BASE}/v1/system/pause" \
+25,
-3
1@@ -57,8 +57,22 @@
2 | `GET` | `/v1/tasks?status=queued&limit=20` | 查看任务列表,可按 `status` 过滤 |
3 | `GET` | `/v1/tasks/:task_id` | 查看单个任务详情 |
4 | `GET` | `/v1/tasks/:task_id/logs?limit=200` | 查看单个任务日志,可按 `run_id` 过滤 |
5-| `GET` | `/v1/runs?limit=20` | 查看运行列表 |
6-| `GET` | `/v1/runs/:run_id` | 查看单个运行详情 |
7+
8+### Codex 会话查询与写入
9+
10+| 方法 | 路径 | 作用 |
11+| --- | --- | --- |
12+| `GET` | `/v1/codex` | 查看当前 `conductor -> codexd` 代理状态 |
13+| `GET` | `/v1/codex/sessions` | 查看当前 Codex sessions |
14+| `GET` | `/v1/codex/sessions/:session_id` | 查看单个 Codex session |
15+| `POST` | `/v1/codex/sessions` | 创建或恢复一个 Codex session |
16+| `POST` | `/v1/codex/turn` | 向已有 session 提交一轮 turn |
17+
18+说明:
19+
20+- 这些 `/v1/codex/*` 路由固定代理到独立 `codexd`
21+- `conductor-daemon` 不自己持有 Codex session 真相
22+- 正式能力只保留 session / turn / status,不对外暴露 `/v1/codex/runs*`
23
24 ## 当前不在业务面讨论的写接口
25
26@@ -96,7 +110,14 @@ curl "${BASE_URL}/v1/tasks?limit=5"
27
28 ```bash
29 BASE_URL="http://100.71.210.78:4317"
30-curl "${BASE_URL}/v1/runs?limit=5"
31+curl "${BASE_URL}/v1/codex"
32+```
33+
34+```bash
35+BASE_URL="http://100.71.210.78:4317"
36+curl -X POST "${BASE_URL}/v1/codex/sessions" \
37+ -H 'Content-Type: application/json' \
38+ -d '{"cwd":"/Users/george/code/baa-conductor","purpose":"duplex"}'
39 ```
40
41 ```bash
42@@ -123,6 +144,7 @@ curl "${BASE_URL}/v1/tasks/${TASK_ID}/logs?limit=50"
43 ## 当前边界
44
45 - 业务类接口当前以“只读查询”为主
46+- `/v1/codex/*` 是少数已经正式开放的业务写接口,但后端固定代理到独立 `codexd`
47 - 控制动作例如 `pause` / `resume` / `drain` 不在本文件讨论范围内
48 - 本机能力接口 `/v1/exec`、`/v1/files/read`、`/v1/files/write` 也不在本文件讨论范围内
49 - 控制类接口见 [`control-interfaces.md`](./control-interfaces.md)
+8,
-0
1@@ -32,6 +32,13 @@
2 5. 如果要做本机 shell / 文件操作,再看 `host_operations`
3 6. 确认当前模式和动作目标后,再执行 `pause` / `resume` / `drain` 或 host-ops
4
5+如果目标是 Codex 会话而不是控制动作:
6+
7+- 改读 [`business-interfaces.md`](./business-interfaces.md)
8+- 先看 `GET /describe/business`
9+- 使用 `/v1/codex/*`
10+- 这些路由固定代理到独立 `codexd`
11+
12 不要在未读状态前直接写入控制动作或本机 host-ops。
13
14 ## Host Ops 鉴权
15@@ -178,5 +185,6 @@ curl -X POST "${BASE_URL}/v1/files/write" \
16
17 - 当前控制面是单节点 `mini`
18 - 控制动作默认作用于当前唯一活动节点
19+- Codex 会话能力不在本文件主讨论范围;它通过 `/v1/codex/*` 代理到独立 `codexd`
20 - 业务查询不在本文件讨论范围内
21 - 业务类接口见 [`business-interfaces.md`](./business-interfaces.md)
1@@ -14,7 +14,7 @@
2 - `codexd` 是唯一 Codex 运行面
3 - `conductor-daemon` 只通过本地接口调用 `codexd`
4 - `codexd` 主接口基于 `codex app-server`
5-- `codex exec` 仅作为 smoke、简单调用和兜底路径
6+- 不实现 `codex exec` 式无交互正式模式
7
8 ## 原因
9
10@@ -43,4 +43,3 @@
11 - 本地命令/查询面:HTTP
12 - 本地双工事件流:WS 或 SSE
13 - 远程 AI / CLI / 网页入口仍通过 `conductor.makefile.so`
14-
+6,
-5
1@@ -35,15 +35,16 @@ Firefox WS 说明:
2 - 维护 `logs/codexd` 和 `state/codexd`
3 - 启动或占位一个 `codex app-server` 子进程配置
4 - 持久化 daemon identity、child state、session registry、recent event cache
5- - 提供 `start` / `status` / `smoke`
6+ - 提供本地 HTTP / WS 服务面
7+ - 让 `conductor-daemon` 通过 `/v1/codex/*` 代理 session / turn / status
8 - 运行约束:
9 - `codexd` 是独立常驻进程,不是 `conductor-daemon` 的内嵌 bridge
10- - `/v1/codex/*` 后续应以 `conductor-daemon -> codexd` 代理方式提供
11+ - `/v1/codex/*` 现在以 `conductor-daemon -> codexd` 代理方式提供
12 - `launchd` 负责自启动和硬重启,不做两个进程互相直接拉起
13 - 当前还没有:
14- - 对外 IPC / HTTP 面
15- - 真正的 `thread` / `turn` 管理
16- - `conductor-daemon -> codexd` 正式接线
17+ - 完整的会话恢复和断线重放
18+ - 更丰富的增量事件订阅语义
19+ - 任何 `/v1/codex/runs*` 的正式代理面
20
21 ## 最短路径
22
+13,
-39
1@@ -7,11 +7,9 @@
2 当前状态:
3
4 - 仓库里已经有 `apps/codexd` 最小骨架
5-- 它目前是 daemon scaffold,不是完整协议实现
6-- 主目标仍然是围绕 `codex app-server` 演进
7-- 已有两个底层适配包:
8- - `packages/codex-app-server`: 面向未来主会话 / 双工能力
9- - `packages/codex-exec`: 面向 smoke、简单 worker 和降级路径的一次性调用
10+- 已经有本地 HTTP / WS 服务面
11+- `conductor-daemon` 已经通过 `/v1/codex/*` 代理到它
12+- 主目标仍然围绕 `codex app-server` 演进
13
14 ## 目标
15
16@@ -129,23 +127,21 @@
17 基于当前本机已验证的 Codex CLI 公开接口,`codexd` 的设计结论应明确为:
18
19 - 主会话与双工能力:基于 `codex app-server`
20-- 简单调用、批处理、测试和兜底 worker:基于 `codex exec`
21+- `conductor-daemon` 对外正式能力只保留 session / turn / status
22 - 不驱动 TUI
23 - 不逆向私有协议
24
25 原因:
26
27-- `codex exec` 适合一次性非交互执行,能输出结构化结果,但天然不是多轮双工模型
28+- 当前目标是常驻、多轮、可恢复的会话代理,不是一次性命令壳
29 - `codex app-server` 虽然仍标记为 experimental,但它已经公开了最完整的线程、turn、流式事件和恢复语义
30 - 已验证单个 `app-server` 进程可承载多个 `thread`,因此默认不需要“一对话一进程”
31
32 当前推荐口径:
33
34 - `codexd v1` 继续围绕 `app-server`
35-- `exec` 仅保留为:
36- - 最小 smoke 测试
37- - 简单离线调用
38- - app-server 不可用时的兜底 worker
39+- `conductor-daemon -> codexd` 是唯一正式代理链路
40+- 不新增、不对外暴露 `/v1/codex/runs*`
41
42 ## 当前骨架已经落下的内容
43
44@@ -190,10 +186,8 @@
45
46 - `thread/start` / `thread/resume` / `turn/start` 的真实代理
47 - `codex-app-server` 传输层接线
48-- HTTP / WS / IPC 入口
49-- conductor-daemon 适配层
50 - crash recovery 的自动复连和 session 恢复
51-- `conductor-daemon -> codexd` 的正式代理面
52+- 更完整的事件恢复和会话恢复能力
53
54 ## 支持的两类工作
55
56@@ -211,8 +205,7 @@
57 - 有 task / step / run 关联
58 - 受 timeout / retry / checkpoint 约束
59 - 由 `worker-runner` / `conductor-daemon` 编排
60-- 默认仍优先走 `app-server`
61-- 只有简单批处理或兜底路径才落到 `exec`
62+- 正式实现只走 `app-server`
63
64 ### 2. duplex 对话模式
65
66@@ -220,7 +213,7 @@
67
68 - 和 AI 的持续双向对话
69 - 支持 CLI / 网页版 AI 参与
70-- 长于一次性 `codex exec`
71+- 长于一次性命令式调用
72
73 特点:
74
75@@ -266,28 +259,9 @@
76 - 增量事件日志
77 - 对 `conductor-daemon` 暴露稳定的本地适配层
78 - 这层至少拆成:
79- - 本地 HTTP:session / turn / run / status
80+ - 本地 HTTP:session / turn / status
81 - 本地 WS 或 SSE:增量事件流
82
83-### v1 兜底能力
84-
85-- 保留一个 `exec` 适配器
86-- 当前仓库里的落点是 `packages/codex-exec`
87-- 只做:
88- - 健康检查
89- - 最小 smoke
90- - 简单一次性 worker 调用
91- - app-server 不可用时的降级路径
92-- 适配层当前只覆盖:
93- - 一次运行一个 `codex exec`
94- - 收集 stdout / stderr
95- - 返回 exit code、timeout、last message 和可选 JSONL 事件
96-- 它不负责:
97- - session / thread 生命周期
98- - 多轮对话
99- - interrupt / steer
100- - 持久化双工事件桥
101-
102 ### v2
103
104 - 在 `app-server` 之上继续补:
105@@ -343,8 +317,8 @@
106 如果开始实现 `codexd`,默认遵守这条约束:
107
108 - `app-server` 是主能力面
109-- `exec` 不是主会话系统,只是简单调用和测试工具
110-- `packages/codex-exec` 只是一个兜底层,不应被扩成主双工实现
111+- 不把 `runs` / `exec` 扩成正式产品能力
112+- `conductor-daemon` 只代理独立 `codexd`
113
114 不要把当前系统误认为已经有:
115