From d82b0c0ba3edc94da05d65203f6635907ccefc58 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sun, 24 May 2026 14:53:14 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8C=81=E4=B9=85=E5=8C=96=20Code=20Age?= =?UTF-8?q?nt=20Codex=20app-server=20=E4=B8=BB=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DC-DCSN-P0-2026-003 / HWLAB #426 --- deploy/README.md | 4 +- deploy/deploy.json | 2 + deploy/deploy.schema.json | 6 + deploy/k8s/base/workloads.yaml | 44 + internal/cloud/code-agent-chat.mjs | 4 +- internal/cloud/code-agent-contract.mjs | 147 ++- .../code-agent-session-registry.test.mjs | 172 ++-- internal/cloud/codex-stdio-session.mjs | 921 +++++++++++++++--- scripts/code-agent-chat-smoke.mjs | 101 +- scripts/src/code-agent-response-contract.mjs | 6 +- scripts/src/deploy-contract-plan.mjs | 72 ++ scripts/validate-contract.mjs | 73 +- 12 files changed, 1281 insertions(+), 271 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 2309a36f..ed88fce9 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -32,8 +32,8 @@ for future compatibility, but PROD deployment is not part of MVP acceptance. `HWLAB_CODE_AGENT_OPENAI_BASE_URL` through the DEV egress/proxy path. The same contract declares `/workspace/hwlab` as a writable workspace, `CODEX_HOME=/codex-home`, `/app/node_modules/.bin/codex` as the Codex command, - and repo-owned MCP stdio supervisor mode. Source checks verify the Secret ref, - base-url, workspace, CODEX_HOME, and CLI contracts without printing API key + and repo-owned `codex app-server --listen stdio://` supervisor mode. Source checks verify the Secret ref, + base-url, workspace, CODEX_HOME, config/auth mounts, NO_PROXY, and CLI contracts without printing API key material. Controlled apply reports also check that the live `hwlab-cloud-api` Deployment preserves the same env names and `secretKeyRef` name/key metadata after apply without reading Kubernetes Secret data. diff --git a/deploy/deploy.json b/deploy/deploy.json index ab2c8d5b..291510e3 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -214,6 +214,8 @@ "HWLAB_CODE_AGENT_CODEX_SANDBOX": "workspace-write", "HWLAB_CODE_AGENT_CODEX_COMMAND": "/app/node_modules/.bin/codex", "CODEX_HOME": "/codex-home", + "NO_PROXY": "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com", + "no_proxy": "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com", "OPENAI_API_KEY": "secretRef:hwlab-code-agent-provider/openai-api-key", "HWLAB_CODE_AGENT_SKILLS_DIRS": "/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills" } diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index 3d866e80..1c0042a9 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -450,6 +450,12 @@ "type": "string", "const": "/codex-home" }, + "NO_PROXY": { + "type": "string" + }, + "no_proxy": { + "type": "string" + }, "OPENAI_API_KEY": { "type": "string", "const": "secretRef:hwlab-code-agent-provider/openai-api-key" diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 532f396a..4a82cdff 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -153,6 +153,14 @@ "name": "CODEX_HOME", "value": "/codex-home" }, + { + "name": "NO_PROXY", + "value": "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com" + }, + { + "name": "no_proxy", + "value": "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com" + }, { "name": "HWLAB_CODE_AGENT_SKILLS_DIRS", "value": "/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills" @@ -188,6 +196,18 @@ { "name": "hwlab-code-agent-codex-home", "mountPath": "/codex-home" + }, + { + "name": "hwlab-code-agent-codex-config", + "mountPath": "/codex-home/config.toml", + "subPath": "config.toml", + "readOnly": true + }, + { + "name": "hwlab-code-agent-codex-auth", + "mountPath": "/codex-home/auth.json", + "subPath": "auth.json", + "readOnly": true } ] } @@ -200,6 +220,30 @@ { "name": "hwlab-code-agent-codex-home", "emptyDir": {} + }, + { + "name": "hwlab-code-agent-codex-config", + "configMap": { + "name": "hwlab-code-agent-codex-config", + "items": [ + { + "key": "config.toml", + "path": "config.toml" + } + ] + } + }, + { + "name": "hwlab-code-agent-codex-auth", + "secret": { + "secretName": "hwlab-code-agent-codex-auth", + "items": [ + { + "key": "auth.json", + "path": "auth.json" + } + ] + } } ] } diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 10cb8397..3263641d 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -1578,7 +1578,7 @@ function detectReadOnlyRunnerIntent(message) { return { kind: "security", toolName: "security.hardware-boundary", - reason: `Code Agent 安全边界不直接调用 gateway/box-simu/patch-panel、泛化硬件写接口或宣称 M3/M4/M5 验收通过;M3 DO1/DI1 只能通过 Codex stdio -> Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}。` + reason: `Code Agent 安全边界不直接调用 gateway/box-simu/patch-panel、泛化硬件写接口或宣称 M3/M4/M5 验收通过;M3 DO1/DI1 只能通过 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}。` }; } const externalNetworkIntent = detectExternalNetworkIntent(text); @@ -2102,7 +2102,7 @@ function codexStdioBlockedRunnerDescriptor({ workspace, session = null } = {}) { readOnly: false, capabilityLevel: "blocked", toolPolicy: { - allowed: ["codex", "codex-reply"], + allowed: ["codex-app-server.thread/start", "codex-app-server.thread/resume", "codex-app-server.turn/start"], blocked: ["controlled-readonly-session-registry", "skills.discover-local-fallback", "pwd-local-fallback", "ls-local-fallback", "rg-local-fallback", "cat-local-fallback", "openai-text-fallback"] }, runnerLimitations: ["codex-stdio-required", "no-controlled-readonly-fallback", "no-text-fallback"], diff --git a/internal/cloud/code-agent-contract.mjs b/internal/cloud/code-agent-contract.mjs index b23f0c7b..f1c6ec61 100644 --- a/internal/cloud/code-agent-contract.mjs +++ b/internal/cloud/code-agent-contract.mjs @@ -5,7 +5,7 @@ export const DEV_CODE_AGENT_PROVIDER_CONTRACT = Object.freeze({ environment: ENVIRONMENT_DEV, codeAgentProvider: "codex-stdio", runtimeProvider: "openai-responses", - backend: "hwlab-cloud-api/codex-mcp-stdio", + backend: "hwlab-cloud-api/codex-app-server-stdio", fallbackBackend: "hwlab-cloud-api/openai-responses", model: "gpt-5.5", requiredEnv: Object.freeze([ @@ -26,6 +26,25 @@ export const DEV_CODE_AGENT_PROVIDER_CONTRACT = Object.freeze({ forbiddenDirectBaseUrl: "https://api.openai.com/v1/responses", reason: "DEV cloud-api pods must use the D601 egress/proxy path for OpenAI Responses." }), + codexHome: Object.freeze({ + path: "/codex-home", + configMapName: "hwlab-code-agent-codex-config", + configKey: "config.toml", + secretName: "hwlab-code-agent-codex-auth", + authKey: "auth.json", + mountContract: "writable emptyDir CODEX_HOME with read-only config.toml/auth.json file mounts" + }), + noProxyRequired: Object.freeze([ + "hyueapi.com", + ".hyueapi.com", + "hyui.com", + ".hyui.com", + "127.0.0.1", + "localhost", + "::1", + "api.minimaxi.com", + ".minimaxi.com" + ]), forbidden: Object.freeze({ prodAllowed: false, secretPlaintextAllowed: false, @@ -47,6 +66,9 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE HWLAB_CODE_AGENT_PROVIDER: contract.codeAgentProvider, HWLAB_CODE_AGENT_MODEL: contract.model, [contract.egress.env]: contract.egress.defaultBaseUrl, + CODEX_HOME: contract.codexHome.path, + NO_PROXY: contract.noProxyRequired.join(","), + no_proxy: contract.noProxyRequired.join(","), [secretRef.env]: expectedSecretRef }; const workloadInspection = inspectCodeAgentProviderWorkloadEnv(workloadEnv); @@ -72,13 +94,30 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE ) { missingEgressContract.push(`${contract.egress.env} must not point directly at public api.openai.com`); } + const missingCodexHomeContract = []; + if (deployEnv?.CODEX_HOME !== contract.codexHome.path || workloadInspection.codexHome.present !== true) { + missingCodexHomeContract.push(`CODEX_HOME must be ${contract.codexHome.path}`); + } + if (workloadInspection.codexHome.configMapPresent !== true) { + missingCodexHomeContract.push(`${contract.codexHome.configMapName}/${contract.codexHome.configKey}`); + } + if (workloadInspection.codexHome.authSecretPresent !== true) { + missingCodexHomeContract.push(`${contract.codexHome.secretName}/${contract.codexHome.authKey}`); + } + const missingNoProxyContract = [...new Set([ + ...noProxyMissing(deployEnv?.NO_PROXY, contract.noProxyRequired), + ...noProxyMissing(deployEnv?.no_proxy, contract.noProxyRequired), + ...workloadInspection.noProxy.missing + ])]; const ready = missingDeployEnv.length === 0 && deployMismatches.length === 0 && workloadInspection.ready === true && missingSecretRefs.length === 0 && - missingEgressContract.length === 0; + missingEgressContract.length === 0 && + missingCodexHomeContract.length === 0 && + missingNoProxyContract.length === 0; return { contractVersion: contract.contractVersion, @@ -123,6 +162,10 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE workloadMismatches: workloadInspection.mismatchedEnv, missingSecretRefs, missingEgressContract, + missingCodexHomeContract, + missingNoProxyContract, + codexHome: workloadInspection.codexHome, + noProxy: workloadInspection.noProxy, secretMaterialRead: false, valuesRedacted: true, providerConnected: false, @@ -137,7 +180,10 @@ export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { const expectedValues = { HWLAB_CODE_AGENT_PROVIDER: contract.codeAgentProvider, HWLAB_CODE_AGENT_MODEL: contract.model, - [contract.egress.env]: contract.egress.defaultBaseUrl + [contract.egress.env]: contract.egress.defaultBaseUrl, + CODEX_HOME: contract.codexHome.path, + NO_PROXY: contract.noProxyRequired.join(","), + no_proxy: contract.noProxyRequired.join(",") }; const requiredEnv = requiredRuntimeEnv.map((name) => { const entry = getEnvEntry(workloadEnv, name); @@ -168,12 +214,22 @@ export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { const baseUrl = envEntryValue(getEnvEntry(workloadEnv, contract.egress.env)); const directPublicOpenAi = isForbiddenDirectOpenAiBaseUrl(baseUrl); const matchesDevProxy = baseUrl === contract.egress.defaultBaseUrl; + const codexHomePresent = envEntryValue(getEnvEntry(workloadEnv, "CODEX_HOME")) === contract.codexHome.path; + const noProxyMissingEntries = [...new Set([ + ...noProxyMissing(envEntryValue(getEnvEntry(workloadEnv, "NO_PROXY")), contract.noProxyRequired), + ...noProxyMissing(envEntryValue(getEnvEntry(workloadEnv, "no_proxy")), contract.noProxyRequired) + ])]; + const codexHome = inspectCodexHomeWorkloadMounts(workloadEnv); const ready = missingEnv.length === 0 && mismatchedEnv.length === 0 && secretPresent && matchesDevProxy && - !directPublicOpenAi; + !directPublicOpenAi && + codexHome.present === true && + codexHome.configMapPresent === true && + codexHome.authSecretPresent === true && + noProxyMissingEntries.length === 0; return { contractVersion: contract.contractVersion, @@ -202,6 +258,15 @@ export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { directPublicOpenAi, valueRedacted: true }, + codexHome: { + ...codexHome, + present: codexHomePresent && codexHome.present === true + }, + noProxy: { + required: [...contract.noProxyRequired], + missing: noProxyMissingEntries, + present: noProxyMissingEntries.length === 0 + }, secretMaterialRead: false, valuesRedacted: true, providerConnected: false @@ -220,9 +285,11 @@ export function buildCodeAgentProviderManifestPlaceholder() { requiredEnv: [...contract.requiredEnv], secretRefs: contract.secretRefs.map((item) => ({ ...item })), egress: { ...contract.egress }, + codexHome: { ...contract.codexHome }, + noProxyRequired: [...contract.noProxyRequired], fixtureEvidence: { providerConnected: false, - summary: "This manifest contract records provider env names, Secret reference names, and DEV egress policy only. It is not live provider evidence." + summary: "This manifest contract records provider env names, Secret/ConfigMap reference names, DEV egress policy, and Codex app-server mount policy only. It is not live provider evidence." } }; } @@ -236,7 +303,10 @@ export function inspectCodeAgentProviderEnv(env = {}) { baseUrl === contract.egress.forbiddenDirectBaseUrl || /^https:\/\/api\.openai\.com\/v1\/responses\/?$/u.test(baseUrl); const egressReady = Boolean(baseUrl) && !directPublicOpenAi; - const ready = missingEnv.length === 0 && egressReady; + const noProxyReady = noProxyMissing(env.NO_PROXY, contract.noProxyRequired).length === 0 && + noProxyMissing(env.no_proxy, contract.noProxyRequired).length === 0; + const codexHomeReady = firstNonEmpty(env.CODEX_HOME) === contract.codexHome.path; + const ready = missingEnv.length === 0 && egressReady && codexHomeReady && noProxyReady; return { contractVersion: contract.contractVersion, @@ -274,6 +344,22 @@ export function inspectCodeAgentProviderEnv(env = {}) { ready: egressReady, valueRedacted: true }, + codexHome: { + path: contract.codexHome.path, + present: codexHomeReady, + configRef: `${contract.codexHome.configMapName}/${contract.codexHome.configKey}`, + authRef: `${contract.codexHome.secretName}/${contract.codexHome.authKey}`, + secretMaterialRead: false, + valuesRedacted: true + }, + noProxy: { + required: [...contract.noProxyRequired], + missing: [...new Set([ + ...noProxyMissing(env.NO_PROXY, contract.noProxyRequired), + ...noProxyMissing(env.no_proxy, contract.noProxyRequired) + ])], + present: noProxyReady + }, safety: { devOnly: true, prodAllowed: contract.forbidden.prodAllowed, @@ -333,6 +419,55 @@ function envEntrySecretKeyRef(entry) { return entry.valueFrom?.secretKeyRef ?? null; } +function inspectCodexHomeWorkloadMounts(workloadEnv = {}) { + const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT; + const volumeMounts = Array.isArray(workloadEnv.__volumeMounts) ? workloadEnv.__volumeMounts : []; + const volumes = Array.isArray(workloadEnv.__volumes) ? workloadEnv.__volumes : []; + const homeMount = volumeMounts.find((entry) => entry?.name === "hwlab-code-agent-codex-home" && entry?.mountPath === contract.codexHome.path); + const configMount = volumeMounts.find((entry) => + entry?.name === "hwlab-code-agent-codex-config" && + entry?.mountPath === `${contract.codexHome.path}/${contract.codexHome.configKey}` && + entry?.subPath === contract.codexHome.configKey && + entry?.readOnly === true + ); + const authMount = volumeMounts.find((entry) => + entry?.name === "hwlab-code-agent-codex-auth" && + entry?.mountPath === `${contract.codexHome.path}/${contract.codexHome.authKey}` && + entry?.subPath === contract.codexHome.authKey && + entry?.readOnly === true + ); + const configVolume = volumes.find((entry) => + entry?.name === "hwlab-code-agent-codex-config" && + entry?.configMap?.name === contract.codexHome.configMapName && + (entry.configMap.items ?? []).some((item) => item?.key === contract.codexHome.configKey && item?.path === contract.codexHome.configKey) + ); + const authVolume = volumes.find((entry) => + entry?.name === "hwlab-code-agent-codex-auth" && + entry?.secret?.secretName === contract.codexHome.secretName && + (entry.secret.items ?? []).some((item) => item?.key === contract.codexHome.authKey && item?.path === contract.codexHome.authKey) + ); + return { + path: contract.codexHome.path, + present: Boolean(homeMount), + writableHomeMountPresent: Boolean(homeMount), + configMapPresent: Boolean(configMount && configVolume), + authSecretPresent: Boolean(authMount && authVolume), + configMapName: contract.codexHome.configMapName, + secretName: contract.codexHome.secretName, + mountContract: contract.codexHome.mountContract, + secretMaterialRead: false, + valuesRedacted: true + }; +} + +function noProxyMissing(value, required) { + const entries = new Set(String(value ?? "") + .split(/[,;\s]+/u) + .map((item) => item.trim().toLowerCase()) + .filter(Boolean)); + return required.filter((item) => !entries.has(String(item).toLowerCase())); +} + function isForbiddenDirectOpenAiBaseUrl(value) { const baseUrl = firstNonEmpty(value); return ( diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 9c94d09f..4d9a78fe 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -1516,6 +1516,57 @@ async function createFakeCodexCommand() { return { root, command }; } +async function prepareFakeCodexHome(root = null) { + const codexHome = root ?? await mkdtemp(path.join(os.tmpdir(), "hwlab-codex-home-")); + await mkdir(codexHome, { recursive: true }); + await writeFile(path.join(codexHome, "config.toml"), "model = \"gpt-test\"\n", "utf8"); + await writeFile(path.join(codexHome, "auth.json"), "{\"auth\":\"redacted-test-placeholder\"}\n", "utf8"); + return codexHome; +} + +function createFakeAppServerClient({ calls, responses = ["first stdio reply", "second stdio reply"], failedTurn = null } = {}) { + let notificationHandler = null; + let turn = 0; + return { + async initialize() { + calls?.push({ method: "initialize" }); + return { initialized: true }; + }, + setNotificationHandler(handler) { + notificationHandler = typeof handler === "function" ? handler : null; + }, + async startThread(args) { + calls?.push({ method: "thread/start", args }); + notificationHandler?.({ method: "thread/started", params: { thread: { id: "thread_stdio_ready" } } }); + return { threadId: "thread_stdio_ready" }; + }, + async resumeThread(args) { + calls?.push({ method: "thread/resume", args }); + notificationHandler?.({ method: "thread/started", params: { thread: { id: args.threadId } } }); + return { threadId: args.threadId }; + }, + async startTurn(args) { + calls?.push({ method: "turn/start", args }); + turn += 1; + const turnId = `turn_stdio_${turn}`; + notificationHandler?.({ method: "turn/started", params: { turn: { id: turnId } } }); + if (failedTurn && turn === failedTurn.turn) { + notificationHandler?.({ + method: "turn/completed", + params: { turn: { id: turnId, status: "failed", error: { message: failedTurn.message } } } + }); + return { turnId }; + } + const text = responses[turn - 1] ?? responses.at(-1) ?? "stdio reply"; + notificationHandler?.({ method: "item/agentMessage/delta", params: { itemId: `item_${turn}`, delta: text } }); + notificationHandler?.({ method: "item/completed", params: { item: { id: `item_${turn}`, type: "agentMessage", text } } }); + notificationHandler?.({ method: "turn/completed", params: { turn: { id: turnId, status: "completed" } } }); + return { turnId }; + }, + close() {} + }; +} + test("Code Agent M3 Skill CLI blocks missing service-local HWLAB API base URL before loopback fallback", async () => { const payload = await handleCodeAgentChat( { @@ -1592,14 +1643,14 @@ test("Codex stdio manager reports concrete blockers without falling back to read ); assert.equal(blocked.status, "failed"); assert.equal(blocked.provider, "codex-stdio"); - assert.equal(blocked.backend, "hwlab-cloud-api/codex-mcp-stdio"); - assert.equal(blocked.error.code, "codex_stdio_blocked"); + assert.equal(blocked.backend, "hwlab-cloud-api/codex-app-server-stdio"); + assert.equal(blocked.error.code, "codex_cli_binary_missing"); assert.equal(blocked.error.layer, "runner"); assert.equal(blocked.error.retryable, false); - assert.match(blocked.error.userMessage, /Codex stdio/u); - assert.equal(blocked.error.blocker.runner, "codex-mcp-stdio-runner"); + assert.match(blocked.error.userMessage, /Codex CLI|Codex stdio/u); + assert.equal(blocked.error.blocker.runner, "codex-app-server-stdio-runner"); assert.ok(blocked.error.blockers.some((blocker) => blocker.code === "codex_cli_binary_missing")); - assert.ok(blocked.error.blockers.some((blocker) => blocker.code === "provider_token_boundary")); + assert.ok(blocked.availability.codexStdio.blockerCodes.includes("stdio_protocol_not_wired")); assert.equal(blocked.capabilityLevel, "blocked"); assert.equal(blocked.longLivedSessionGate.status, "blocked"); assert.equal(blocked.availability.ready, false); @@ -1612,6 +1663,7 @@ test("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and versi const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-codex-home-")); const fakeCodex = await createFakeCodexCommand(); try { + await prepareFakeCodexHome(codexHome); const manager = createCodexStdioSessionManager(); const availability = manager.describe({ env: { @@ -1640,6 +1692,12 @@ test("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and versi assert.equal(availability.codexHomeState.readable, true); assert.equal(availability.codexHomeState.writable, true); assert.equal(availability.runtimeContract.codexHome.status, "ready"); + assert.equal(availability.codexHomeFiles.configPresent, true); + assert.equal(availability.codexHomeFiles.authPresent, true); + assert.equal(availability.childEnvBoundary.forbiddenEnvPresent, false); + assert.ok(availability.childEnvBoundary.strippedEnvKeys.includes("OPENAI_API_KEY")); + assert.ok(availability.childEnvBoundary.strippedEnvKeys.includes("HWLAB_CODE_AGENT_OPENAI_BASE_URL")); + assert.deepEqual(availability.childEnvBoundary.noProxy.missing, []); assert.equal(availability.startupReady, true); assert.equal(availability.canStartLongLivedCodexStdio, true); assert.equal(availability.ready, false); @@ -1658,38 +1716,23 @@ test("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and versi test("repo-owned Codex stdio manager creates and reuses long-lived sessions with trace evidence", async () => { const calls = []; const fakeCodex = await createFakeCodexCommand(); + const codexHome = await prepareFakeCodexHome(); const registry = createCodeAgentSessionRegistry(); const manager = createCodexStdioSessionManager({ idFactory: () => "ses_stdio_ready", - createRpcClient: async () => ({ - async initialize() { - return { tools: ["codex", "codex-reply"] }; - }, - async listTools() { - return ["codex", "codex-reply"]; - }, - async callTool(name, args) { - calls.push({ name, args }); - return { - structuredContent: { - threadId: "thread_stdio_ready", - content: name === "codex" ? "first stdio reply" : "second stdio reply" - } - }; - }, - close() {} - }) + createRpcClient: async () => createFakeAppServerClient({ calls }) }); const env = { PATH: process.env.PATH, OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: process.cwd(), + CODEX_HOME: codexHome, HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" }; const first = await handleCodeAgentChat( @@ -1708,9 +1751,9 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with validateCodeAgentChatSchema(first); assert.equal(first.status, "completed"); assert.equal(first.provider, "codex-stdio"); - assert.equal(first.runner.kind, "codex-mcp-stdio-runner"); - assert.equal(first.sessionMode, "codex-mcp-stdio-long-lived"); - assert.equal(first.implementationType, "repo-owned-codex-mcp-stdio-session"); + assert.equal(first.runner.kind, "codex-app-server-stdio-runner"); + assert.equal(first.sessionMode, "codex-app-server-stdio-long-lived"); + assert.equal(first.implementationType, "repo-owned-codex-app-server-stdio-session"); assert.equal(first.session.longLivedSession, true); assert.equal(first.session.codexStdio, true); assert.equal(first.runner.writeCapable, true); @@ -1746,25 +1789,35 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with assert.equal(second.runnerTrace.sessionReused, true); assert.equal(second.workspace, first.workspace); assert.equal(second.sandbox, first.sandbox); - assert.equal(second.toolCalls[0].name, "codex-reply"); + assert.equal(second.toolCalls[0].name, "codex-app-server.thread/resume+turn/start"); assert.equal(second.reply.content, "second stdio reply"); - assert.equal(calls[0].name, "codex"); - assert.equal(calls[1].name, "codex-reply"); - assert.equal(calls[1].args.threadId, "thread_stdio_ready"); - assert.match(calls[1].args.prompt, /Prior session facts/u); - assert.match(calls[1].args.prompt, /ses_stdio_ready/u); - assert.match(calls[1].args.prompt, /trc_stdio_ready_1/u); - assert.match(calls[1].args.prompt, new RegExp(escapeRegExp(process.cwd()), "u")); + const turnPathCalls = calls.filter((call) => call.method !== "initialize"); + assert.deepEqual(turnPathCalls.map((call) => call.method), ["thread/start", "turn/start", "thread/resume", "turn/start"]); + assert.equal(turnPathCalls[0].args.cwd, process.cwd()); + assert.equal(turnPathCalls[2].args.threadId, "thread_stdio_ready"); + assert.match(turnPathCalls[3].args.prompt, /Prior session facts/u); + assert.match(turnPathCalls[3].args.prompt, /ses_stdio_ready/u); + assert.match(turnPathCalls[3].args.prompt, /trc_stdio_ready_1/u); + assert.match(turnPathCalls[3].args.prompt, new RegExp(escapeRegExp(process.cwd()), "u")); + assert.equal(second.providerTrace.protocol, "codex-app-server-jsonrpc-stdio"); await rm(fakeCodex.root, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); }); test("Codex stdio runner startup failure marks session failed and blocks long-lived gate", async () => { const fakeCodex = await createFakeCodexCommand(); + const codexHome = await prepareFakeCodexHome(); const manager = createCodexStdioSessionManager({ idFactory: () => "ses_stdio_start_failed", + createProbeRpcClient: async () => ({ + async initialize() { + return { initialized: true }; + }, + close() {} + }), createRpcClient: async () => ({ async initialize() { - throw new Error("MCP initialize failed with sk-test-secret and TOKEN=abc123"); + throw new Error("app-server initialize failed with sk-test-secret and TOKEN=abc123"); }, close() {} }) @@ -1772,13 +1825,14 @@ test("Codex stdio runner startup failure marks session failed and blocks long-li const env = { PATH: process.env.PATH, OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: process.cwd(), + CODEX_HOME: codexHome, HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" }; const payload = await handleCodeAgentChat( @@ -1800,7 +1854,7 @@ test("Codex stdio runner startup failure marks session failed and blocks long-li assert.equal(payload.error.code, "codex_stdio_failed"); assert.equal(payload.session.sessionId, "ses_stdio_start_failed"); assert.equal(payload.session.status, "failed"); - assert.equal(payload.session.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(payload.session.sessionMode, "codex-app-server-stdio-long-lived"); assert.equal(payload.session.statusReason, "codex_stdio_failed"); assert.equal(payload.longLivedSessionGate.status, "blocked"); assert.equal(payload.longLivedSessionGate.pass, false); @@ -1813,38 +1867,37 @@ test("Codex stdio runner startup failure marks session failed and blocks long-li assert.equal(serialized.includes("TOKEN=abc123"), false); assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false); await rm(fakeCodex.root, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); }); -test("Codex stdio protocol missing tools is a structured blocker, not a passed session", async () => { +test("Codex app-server failed turn is not wrapped as completed assistant output", async () => { + const calls = []; const fakeCodex = await createFakeCodexCommand(); + const codexHome = await prepareFakeCodexHome(); const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_stdio_missing_tool", - createRpcClient: async () => ({ - async initialize() { - return { tools: ["codex"] }; - }, - async listTools() { - return ["codex"]; - }, - close() {} + idFactory: () => "ses_stdio_failed_turn", + createRpcClient: async () => createFakeAppServerClient({ + calls, + failedTurn: { turn: 1, message: "provider network failed with sk-test-secret and TOKEN=abc123" } }) }); const env = { PATH: process.env.PATH, OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: process.cwd(), + CODEX_HOME: codexHome, HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" }; const payload = await handleCodeAgentChat( { - conversationId: "cnv_stdio_missing_tool", - traceId: "trc_stdio_missing_tool", + conversationId: "cnv_stdio_failed_turn", + traceId: "trc_stdio_failed_turn", message: "需要长会话" }, { @@ -1856,14 +1909,15 @@ test("Codex stdio protocol missing tools is a structured blocker, not a passed s validateCodeAgentChatSchema(payload); assert.equal(payload.status, "failed"); - assert.equal(payload.error.code, "codex_stdio_protocol_blocked"); - assert.deepEqual(payload.error.missingTools, ["codex-reply"]); + assert.equal(payload.error.code, "codex_stdio_failed"); + assert.equal(Object.hasOwn(payload, "reply"), false); assert.equal(payload.session.status, "failed"); assert.equal(payload.longLivedSessionGate.status, "blocked"); assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "session_failed")); - assert.equal(payload.availability.codexStdio.ready, false); - assert.ok(payload.availability.codexStdio.blockerCodes.includes("stdio_protocol_not_wired")); - assert.deepEqual(payload.codexStdioFeasibility.protocol.toolsObserved, ["codex"]); + assert.equal(payload.error.message.includes("sk-test-secret"), false); + assert.equal(payload.error.message.includes("TOKEN=abc123"), false); + assert.deepEqual(calls.filter((call) => call.method !== "initialize").map((call) => call.method), ["thread/start", "turn/start"]); assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false); await rm(fakeCodex.root, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); }); diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index b2438655..3abb4713 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -1,7 +1,7 @@ import { spawn, spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { lookup as dnsLookup } from "node:dns/promises"; -import { accessSync, constants as fsConstants, existsSync, realpathSync } from "node:fs"; +import { accessSync, constants as fsConstants, existsSync, realpathSync, statSync } from "node:fs"; import { mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises"; import { isIP } from "node:net"; import os from "node:os"; @@ -15,10 +15,10 @@ import { } from "./code-agent-trace-store.mjs"; export const CODEX_STDIO_PROVIDER = "codex-stdio"; -export const CODEX_STDIO_BACKEND = "hwlab-cloud-api/codex-mcp-stdio"; -export const CODEX_STDIO_RUNNER_KIND = "codex-mcp-stdio-runner"; -export const CODEX_STDIO_SESSION_MODE = "codex-mcp-stdio-long-lived"; -export const CODEX_STDIO_IMPLEMENTATION_TYPE = "repo-owned-codex-mcp-stdio-session"; +export const CODEX_STDIO_BACKEND = "hwlab-cloud-api/codex-app-server-stdio"; +export const CODEX_STDIO_RUNNER_KIND = "codex-app-server-stdio-runner"; +export const CODEX_STDIO_SESSION_MODE = "codex-app-server-stdio-long-lived"; +export const CODEX_STDIO_IMPLEMENTATION_TYPE = "repo-owned-codex-app-server-stdio-session"; export const CODEX_STDIO_CAPABILITY_LEVEL = "long-lived-codex-stdio-session"; export const CODEX_STDIO_SANDBOX = "workspace-write"; export const DEFAULT_CODEX_STDIO_IDLE_TIMEOUT_MS = 30 * 60 * 1000; @@ -39,8 +39,8 @@ export const CODEX_STDIO_SESSION_STATUSES = Object.freeze([ ]); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const MCP_PROTOCOL_VERSION = "2024-11-05"; -const CODEX_STDIO_REQUIRED_TOOLS = Object.freeze(["codex", "codex-reply"]); +const CODEX_APP_SERVER_PROTOCOL = "codex-app-server-jsonrpc-stdio"; +const CODEX_APP_SERVER_REQUIRED_METHODS = Object.freeze(["initialize", "thread/start", "thread/resume", "turn/start"]); const CODEX_STDIO_TOOL_OUTPUT_LIMIT = 4000; const CODEX_STDIO_SKILL_LIMIT = 40; const CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS = 3000; @@ -50,6 +50,33 @@ const EXTERNAL_NETWORK_TOOL_NAME = "external.network.check"; const EXTERNAL_NETWORK_DEFAULT_TIMEOUT_MS = 8000; const EXTERNAL_NETWORK_DNS_TIMEOUT_MS = 1500; const EXTERNAL_NETWORK_HTTP_METHOD = "HEAD"; +const CODEX_CHILD_STRIPPED_ENV_KEYS = Object.freeze([ + "OPENAI_API_KEY", + "CODEX_API_KEY", + "HWLAB_CODE_AGENT_OPENAI_BASE_URL", + "OPENAI_BASE_URL", + "OPENAI_API_BASE", + "OPENAI_RESPONSES_URL", + "CODEX_OPENAI_BASE_URL", + "CODEX_BASE_URL", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy" +]); +const CODEX_CHILD_NO_PROXY_REQUIRED = Object.freeze([ + "hyueapi.com", + ".hyueapi.com", + "hyui.com", + ".hyui.com", + "127.0.0.1", + "localhost", + "::1", + "api.minimaxi.com", + ".minimaxi.com" +]); const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [ "You are the HWLAB Cloud Workbench Code Agent.", "Use the provided workspace and repo-owned Codex stdio session only.", @@ -70,7 +97,7 @@ export function createCodexStdioSessionManager(options = {}) { const conversations = new Map(); let rpcClient = options.rpcClient ?? null; let rpcStartedAt = null; - let rpcToolNames = null; + let rpcInitialized = false; let protocolProbe = null; let commandProbe = null; @@ -81,18 +108,20 @@ export function createCodexStdioSessionManager(options = {}) { const sandbox = resolveCodexSandbox(env, params); const enabled = codexStdioEnabled(env, params, options); const supervisor = supervisorState(env, params, options, enabled); - const tokenBoundary = tokenBoundaryState(env); const binary = codexBinaryState(command, env); const binaryOnPath = binary.present; const workspaceInfo = workspaceStateSync(workspace, sandbox); const codexHome = resolveCodexHome(env); const codexHomeInfo = directoryStateSync(codexHome, { writableRequired: true }); + const codexHomeFiles = codexHomeFilesStateSync(codexHome); + const tokenBoundary = tokenBoundaryState(env, codexHomeFiles); const egress = egressState(env); + const childEnvBoundary = childProcessEnvState(env); const protocol = protocolState({ command, binary, supervisor, - toolsObserved: params.toolsObserved ?? rpcToolNames ?? cachedProbeTools({ command, workspace, codexHome, probe: protocolProbe }) + initialized: params.protocolInitialized ?? (rpcInitialized || cachedProtocolReady({ command, workspace, codexHome, probe: protocolProbe })) }); const lifecycle = lifecycleState({ supervisor, @@ -181,18 +210,39 @@ export function createCodexStdioSessionManager(options = {}) { summary: `Configured CODEX_HOME is not writable for Codex session state: ${codexHome}.` }); } + if (codexHomeFiles.configPresent !== true || codexHomeFiles.configReadable !== true) { + blockers.push({ + code: "codex_home_config_missing", + sourceIssue: "pikasTech/HWLAB#426", + summary: `Configured CODEX_HOME does not expose readable Codex config.toml: ${codexHomeFiles.configPath}.` + }); + } + if (codexHomeFiles.authPresent !== true || codexHomeFiles.authReadable !== true) { + blockers.push({ + code: "codex_home_auth_missing", + sourceIssue: "pikasTech/HWLAB#426", + summary: `Configured CODEX_HOME does not expose readable Codex auth.json: ${codexHomeFiles.authPath}.` + }); + } if (!tokenBoundary.present) { blockers.push({ code: "provider_token_boundary", - sourceIssue: "pikasTech/HWLAB#275", - summary: "A long-lived Codex stdio runner needs a repo-owned provider token boundary; this endpoint only reports token presence and never reads or prints token material." + sourceIssue: "pikasTech/HWLAB#426", + summary: "A long-lived Codex app-server runner needs a repo-owned /codex-home auth boundary; this endpoint only reports file presence and never reads or prints token material." + }); + } + if (childEnvBoundary.forbiddenEnvPresent) { + blockers.push({ + code: "codex_child_env_polluted", + sourceIssue: "pikasTech/HWLAB#426", + summary: "Codex child process environment still contains provider/proxy env keys that must be stripped before app-server startup." }); } if (egress.directPublicOpenAi) { blockers.push({ code: "codex_stdio_egress_boundary", - sourceIssue: "pikasTech/HWLAB#275", - summary: "Codex stdio provider egress must use the DEV egress/proxy boundary, not direct public api.openai.com." + sourceIssue: "pikasTech/HWLAB#426", + summary: "Codex app-server provider egress must use /codex-home config/auth and DEV egress boundary, not direct public api.openai.com env fallback." }); } @@ -214,11 +264,13 @@ export function createCodexStdioSessionManager(options = {}) { workspaceState: workspaceInfo, codexHome, codexHomeState: codexHomeInfo, + codexHomeFiles, sandbox, enabled, supervisor, tokenBoundary, egress, + childEnvBoundary, protocol, stdioProtocol: protocol, protocolProbe: protocolProbeSummary({ command, workspace, codexHome, probe: protocolProbe }), @@ -239,8 +291,10 @@ export function createCodexStdioSessionManager(options = {}) { sandbox, codexHome, codexHomeInfo, + codexHomeFiles, tokenBoundary, egress, + childEnvBoundary, commandProbe: commandProbeState }), blockers, @@ -371,20 +425,8 @@ export function createCodexStdioSessionManager(options = {}) { now }); for (const event of sidecar.events) traceRecorder.append(event); - const toolName = session.threadId ? "codex-reply" : "codex"; - const toolArguments = session.threadId - ? { - threadId: session.threadId, - prompt: buildCodexUserPrompt(params.message, { conversationId, traceId, conversationFacts: params.conversationFacts }) - } - : { - prompt: buildCodexUserPrompt(params.message, { conversationId, traceId, conversationFacts: params.conversationFacts }), - cwd: workspace, - sandbox, - model: params.model, - "approval-policy": "never", - "developer-instructions": CODEX_STDIO_BOUNDARY_INSTRUCTIONS - }; + const prompt = buildCodexUserPrompt(params.message, { conversationId, traceId, conversationFacts: params.conversationFacts }); + const toolName = session.threadId ? "codex-app-server.thread/resume+turn/start" : "codex-app-server.thread/start+turn/start"; traceRecorder.append({ type: "prompt", status: "sent", @@ -394,7 +436,7 @@ export function createCodexStdioSessionManager(options = {}) { sessionId: session.sessionId, sessionStatus: session.status, turn: session.turn, - waitingFor: `tool:${toolName}` + waitingFor: "app-server-thread" }); traceRecorder.append({ type: "tool_call", @@ -404,25 +446,47 @@ export function createCodexStdioSessionManager(options = {}) { sessionId: session.sessionId, sessionStatus: session.status, turn: session.turn, - waitingFor: `tool:${toolName}:output` + waitingFor: "app-server-turn" + }); + const turnResult = await runAppServerTurn({ + client, + session, + prompt, + workspace, + sandbox, + model: firstNonEmpty(params.model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"), + traceRecorder, + timeoutMs: effectiveTimeout(params.timeoutMs) }); - const toolResult = await client.callTool(toolName, dropEmpty(toolArguments), effectiveTimeout(params.timeoutMs)); traceRecorder.append({ type: "tool_call", status: "output_chunk", label: `tool:${toolName}:output_chunk`, toolName, - outputSummary: summarizeToolResult(toolResult), + outputSummary: summarizeAppServerTurn(turnResult), sessionId: session.sessionId, sessionStatus: session.status, turn: session.turn, waitingFor: "assistant-message" }); - const codexOutput = extractCodexToolOutput(toolResult); - if (!codexOutput.content) { - throw codexStdioError("codex_stdio_empty_response", "Codex stdio tool call completed without assistant content.", { + if (turnResult.terminalStatus !== "completed") { + throw codexStdioError("codex_stdio_failed", redactText(turnResult.terminalError || `Codex app-server turn finished with status=${turnResult.terminalStatus || "unknown"}.`), { availability, - session + session, + terminalStatus: turnResult.terminalStatus, + threadId: turnResult.threadId, + turnId: turnResult.turnId, + appServerExit: turnResult.appServerExit + }); + } + const assistantContent = redactText(firstNonEmpty(turnResult.finalResponse, turnResult.assistantText)); + if (!assistantContent) { + throw codexStdioError("codex_stdio_empty_response", "Codex app-server turn completed without assistant content.", { + availability, + session, + terminalStatus: turnResult.terminalStatus, + threadId: turnResult.threadId, + turnId: turnResult.turnId }); } session = releaseSession(session.sessionId, { @@ -430,15 +494,15 @@ export function createCodexStdioSessionManager(options = {}) { traceId, conversationId, reused: session.reused, - threadId: codexOutput.threadId ?? session.threadId, - status: "idle" - }) ?? session; + threadId: turnResult.threadId ?? session.threadId, + status: "idle" + }) ?? session; traceRecorder.append({ type: "tool_call", status: "completed", label: `tool:${toolName}:completed`, toolName, - outputSummary: codexOutput.threadId ? `threadId=${codexOutput.threadId}` : "threadId=captured", + outputSummary: turnResult.threadId ? `threadId=${turnResult.threadId} turnId=${turnResult.turnId ?? "unknown"}` : "threadId=captured", sessionId: session.sessionId, sessionStatus: session.status, turn: session.turn @@ -447,7 +511,7 @@ export function createCodexStdioSessionManager(options = {}) { type: "assistant_message", status: "chunk", label: "assistant:chunk", - chunk: codexOutput.content.slice(0, 400), + chunk: assistantContent.slice(0, 400), sessionId: session.sessionId, sessionStatus: session.status, turn: session.turn, @@ -466,7 +530,7 @@ export function createCodexStdioSessionManager(options = {}) { provider: CODEX_STDIO_PROVIDER, model: firstNonEmpty(params.model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"), backend: CODEX_STDIO_BACKEND, - content: codexReplyWithSidecar(codexOutput.content, sidecar), + content: codexReplyWithSidecar(assistantContent, sidecar), workspace, sandbox, session, @@ -485,13 +549,17 @@ export function createCodexStdioSessionManager(options = {}) { }), toolCalls: [{ id: `tool_${randomUUID()}`, - type: "codex-stdio", + type: "codex-app-server-stdio", name: toolName, status: "completed", cwd: workspace, - command: "codex mcp-server tools/call", + command: "codex app-server --listen stdio://", exitCode: 0, - stdout: codexOutput.threadId ? `threadId=${codexOutput.threadId}` : "threadId=captured", + stdout: [ + turnResult.threadId ? `threadId=${turnResult.threadId}` : "threadId=captured", + turnResult.turnId ? `turnId=${turnResult.turnId}` : null, + `terminalStatus=${turnResult.terminalStatus}` + ].filter(Boolean).join(" "), stderrSummary: "", outputTruncated: false, traceId @@ -510,10 +578,12 @@ export function createCodexStdioSessionManager(options = {}) { capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, providerTrace: { transport: "stdio", - protocol: "mcp", - command: `${availability.command} mcp-server`, + protocol: CODEX_APP_SERVER_PROTOCOL, + command: `${availability.command} app-server --listen stdio://`, toolName, - threadId: codexOutput.threadId ?? session.threadId ?? null, + threadId: turnResult.threadId ?? session.threadId ?? null, + turnId: turnResult.turnId ?? null, + terminalStatus: turnResult.terminalStatus, valuesPrinted: false } }; @@ -604,8 +674,8 @@ export function createCodexStdioSessionManager(options = {}) { let availability = describe({ ...params, env, workspace, command, sandbox }); const startupBlockers = availability.blockers.filter((blocker) => blocker.code !== "stdio_protocol_not_wired"); if (startupBlockers.length > 0) return availability; - if (rpcClient && Array.isArray(rpcToolNames) && rpcToolNames.length > 0) { - availability = describe({ ...params, env, workspace, command, sandbox, toolsObserved: rpcToolNames }); + if (rpcClient && rpcInitialized === true) { + availability = describe({ ...params, env, workspace, command, sandbox, protocolInitialized: true }); return ensureCommandProbeReady({ env, workspace, @@ -617,8 +687,8 @@ export function createCodexStdioSessionManager(options = {}) { probeTimeoutMs: params.commandProbeTimeoutMs }); } - if (!params.forceProbe && cachedProbeTools({ command, workspace, codexHome, probe: protocolProbe })) { - availability = describe({ ...params, env, workspace, command, sandbox, toolsObserved: protocolProbe.toolsObserved }); + if (!params.forceProbe && cachedProtocolReady({ command, workspace, codexHome, probe: protocolProbe })) { + availability = describe({ ...params, env, workspace, command, sandbox, protocolInitialized: true }); return ensureCommandProbeReady({ env, workspace, @@ -638,33 +708,28 @@ export function createCodexStdioSessionManager(options = {}) { ? await options.createProbeRpcClient({ env, availability }) : options.createRpcClient ? await options.createRpcClient({ env, availability, probe: true }) - : createCodexMcpJsonLineClient({ + : createCodexAppServerJsonLineClient({ command: availability.command, env: childProcessEnv(env), cwd: availability.workspace }); const timeoutMs = positiveInteger(params.probeTimeoutMs, 10000); - const init = typeof probeClient.initialize === "function" ? await probeClient.initialize(timeoutMs) : null; - const toolsObserved = Array.isArray(init?.tools) - ? init.tools - : typeof probeClient.listTools === "function" - ? await probeClient.listTools(timeoutMs) - : []; - rpcToolNames = toolsObserved; + if (typeof probeClient.initialize === "function") await probeClient.initialize(timeoutMs); protocolProbe = { status: "ready", ready: true, command, workspace, codexHome, - toolsObserved, + initialized: true, + requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS], startedAt, finishedAt: timestampFor(params.now ?? nowDefault), expiresAtMs: Date.now() + probeCacheTtlMs, secretMaterialRead: false, valuesRedacted: true }; - availability = describe({ ...params, env, workspace, command, sandbox, toolsObserved }); + availability = describe({ ...params, env, workspace, command, sandbox, protocolInitialized: true }); return ensureCommandProbeReady({ env, workspace, @@ -682,7 +747,8 @@ export function createCodexStdioSessionManager(options = {}) { command, workspace, codexHome, - toolsObserved: [], + initialized: false, + requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS], startedAt, finishedAt: timestampFor(params.now ?? nowDefault), expiresAtMs: Date.now() + Math.min(probeCacheTtlMs, 5000), @@ -690,7 +756,7 @@ export function createCodexStdioSessionManager(options = {}) { secretMaterialRead: false, valuesRedacted: true }; - return describe({ ...params, env, workspace, command, sandbox, toolsObserved: [] }); + return describe({ ...params, env, workspace, command, sandbox, protocolInitialized: false }); } finally { if (probeClient && typeof probeClient.close === "function") { probeClient.close(); @@ -750,7 +816,7 @@ export function createCodexStdioSessionManager(options = {}) { } rpcClient = null; rpcStartedAt = null; - rpcToolNames = null; + rpcInitialized = false; protocolProbe = null; commandProbe = null; } @@ -766,7 +832,7 @@ export function createCodexStdioSessionManager(options = {}) { probeTimeoutMs } = {}) { if (cachedCommandProbe({ command, workspace, codexHome, probe: commandProbe })) { - return describe({ env, workspace, command, sandbox, toolsObserved: availability.protocol?.toolsObserved ?? rpcToolNames }); + return describe({ env, workspace, command, sandbox, protocolInitialized: availability.protocol?.initialized ?? rpcInitialized }); } const startedAt = timestampFor(now); try { @@ -829,40 +895,22 @@ export function createCodexStdioSessionManager(options = {}) { valuesRedacted: true }; } - return describe({ env, workspace, command, sandbox, toolsObserved: availability.protocol?.toolsObserved ?? rpcToolNames }); + return describe({ env, workspace, command, sandbox, protocolInitialized: availability.protocol?.initialized ?? rpcInitialized }); } async function ensureRpcClient({ env, availability, timeoutMs } = {}) { - if (rpcClient && typeof rpcClient.callTool === "function") return rpcClient; + if (rpcClient && typeof rpcClient.startTurn === "function") return rpcClient; rpcClient = options.createRpcClient ? await options.createRpcClient({ env, availability }) - : createCodexMcpJsonLineClient({ + : createCodexAppServerJsonLineClient({ command: availability.command, env: childProcessEnv(env), cwd: availability.workspace }); if (typeof rpcClient.initialize === "function") { - const init = await rpcClient.initialize(effectiveTimeout(timeoutMs)); - rpcToolNames = Array.isArray(init?.tools) ? init.tools : rpcToolNames; - } - if (!rpcToolNames && typeof rpcClient.listTools === "function") { - rpcToolNames = await rpcClient.listTools(effectiveTimeout(timeoutMs)); - } - const missingTools = CODEX_STDIO_REQUIRED_TOOLS.filter((name) => !rpcToolNames?.includes(name)); - if (missingTools.length > 0) { - const observedTools = Array.isArray(rpcToolNames) ? [...rpcToolNames] : []; - closeRpcClient(); - throw codexStdioError("codex_stdio_protocol_blocked", `Codex stdio server did not expose required tools: ${missingTools.join(", ")}.`, { - availability: describe({ - env, - workspace: availability.workspace, - sandbox: availability.sandbox, - command: availability.command, - toolsObserved: observedTools - }), - missingTools - }); + await rpcClient.initialize(effectiveTimeout(timeoutMs)); } + rpcInitialized = true; rpcStartedAt = timestampFor(nowDefault); return rpcClient; } @@ -1046,8 +1094,8 @@ export function createCodexStdioSessionManager(options = {}) { }; } -export function createCodexMcpJsonLineClient({ command = DEFAULT_CODEX_STDIO_COMMAND, env = process.env, cwd = repoRoot } = {}) { - const child = spawn(command, ["mcp-server"], { +export function createCodexAppServerJsonLineClient({ command = DEFAULT_CODEX_STDIO_COMMAND, env = process.env, cwd = repoRoot, onNotification = null } = {}) { + const child = spawn(command, ["app-server", "--listen", "stdio://"], { cwd, env, stdio: ["pipe", "pipe", "pipe"] @@ -1057,6 +1105,12 @@ export function createCodexMcpJsonLineClient({ command = DEFAULT_CODEX_STDIO_COM let stderr = ""; const pending = new Map(); let initialized = false; + let notificationHandler = typeof onNotification === "function" ? onNotification : null; + let closed = false; + let closeResolve; + const closedPromise = new Promise((resolve) => { + closeResolve = resolve; + }); child.stdout.on("data", (chunk) => { stdoutBuffer += chunk.toString("utf8"); @@ -1071,8 +1125,8 @@ export function createCodexMcpJsonLineClient({ command = DEFAULT_CODEX_STDIO_COM child.stderr.on("data", (chunk) => { stderr = tailText(`${stderr}${chunk.toString("utf8")}`, 4000); }); - child.on("error", (error) => rejectAll(`Codex stdio process error: ${error.message}`)); - child.on("close", (code, signal) => rejectAll(`Codex stdio process closed code=${code ?? "null"} signal=${signal ?? "null"}`)); + child.on("error", (error) => closeWith(`Codex app-server process error: ${error.message}`, 127, "error")); + child.on("close", (code, signal) => closeWith(`Codex app-server process closed code=${code ?? "null"} signal=${signal ?? "null"}`, code, signal)); function handleLine(line) { let payload = null; @@ -1081,30 +1135,51 @@ export function createCodexMcpJsonLineClient({ command = DEFAULT_CODEX_STDIO_COM } catch { return; } - const request = pending.get(payload.id); - if (!request) return; - pending.delete(payload.id); - clearTimeout(request.timer); - if (payload.error) { - request.reject(new Error(redactText(payload.error.message ?? JSON.stringify(payload.error)))); - } else { - request.resolve(payload.result); + const id = typeof payload.id === "number" ? payload.id : null; + const method = typeof payload.method === "string" ? payload.method : null; + if (id !== null && method === null) { + const request = pending.get(id); + if (!request) return; + pending.delete(id); + clearTimeout(request.timer); + if (payload.error) { + request.reject(new Error(redactText(payload.error.message ?? JSON.stringify(payload.error)))); + } else { + request.resolve(payload.result); + } + return; + } + if (id !== null && method !== null) { + handleServerRequest(id, method); + return; + } + if (method !== null && notificationHandler) { + notificationHandler(payload); } } - function rejectAll(message) { + function closeWith(message, code, signal) { + if (closed) return; + closed = true; for (const [id, request] of pending.entries()) { pending.delete(id); clearTimeout(request.timer); request.reject(new Error(redactText(`${message}; stderr=${tailText(stderr, 800)}`))); } + closeResolve({ + code: Number.isInteger(code) ? code : null, + signal: typeof signal === "string" ? signal : null, + stderrTail: redactText(tailText(stderr, 8000)) + }); } function request(method, params = {}, timeoutMs = 30000) { + if (closed) { + return Promise.reject(new Error("Codex app-server process is already closed.")); + } const id = nextId; nextId += 1; const message = JSON.stringify({ - jsonrpc: "2.0", id, method, params @@ -1125,44 +1200,458 @@ export function createCodexMcpJsonLineClient({ command = DEFAULT_CODEX_STDIO_COM } async function initialize(timeoutMs = 30000) { - if (initialized) return { tools: await listTools(timeoutMs) }; + if (initialized) return { initialized: true, requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS] }; await request("initialize", { - protocolVersion: MCP_PROTOCOL_VERSION, - capabilities: {}, clientInfo: { name: "hwlab-cloud-api", + title: "HWLAB Cloud API Code Agent", version: "0" - } + }, + capabilities: { experimentalApi: true } }, timeoutMs); - child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`); + notify("initialized", {}); initialized = true; - return { tools: await listTools(timeoutMs) }; + return { initialized: true, requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS] }; } - async function listTools(timeoutMs = 30000) { - const result = await request("tools/list", {}, timeoutMs); - return Array.isArray(result?.tools) ? result.tools.map((tool) => tool?.name).filter(Boolean) : []; + function notify(method, params = {}) { + child.stdin.write(`${JSON.stringify({ method, params })}\n`); } - async function callTool(name, args = {}, timeoutMs = 150000) { - return request("tools/call", { - name, - arguments: args - }, timeoutMs); + function handleServerRequest(id, method) { + if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") { + child.stdin.write(`${JSON.stringify({ id, result: { decision: "decline" } })}\n`); + return; + } + child.stdin.write(`${JSON.stringify({ id, error: { code: -32601, message: `Unsupported client-side request: ${method}` } })}\n`); + } + + async function startThread({ model, cwd: threadCwd, sandbox, approvalPolicy = "never", serviceName = "hwlab-cloud-api" } = {}, timeoutMs = 30000) { + const result = await request("thread/start", dropEmpty({ + model, + cwd: threadCwd, + sandbox, + approvalPolicy, + serviceName + }), timeoutMs); + const threadId = extractAppServerString(extractAppServerRecord(result)?.thread, "id"); + if (!threadId) throw new Error("thread/start response did not include thread.id"); + return { threadId, result }; + } + + async function resumeThread({ threadId, model, cwd: threadCwd, sandbox, approvalPolicy = "never" } = {}, timeoutMs = 30000) { + const result = await request("thread/resume", dropEmpty({ + threadId, + model, + cwd: threadCwd, + sandbox, + approvalPolicy + }), timeoutMs); + const resumedThreadId = extractAppServerString(extractAppServerRecord(result)?.thread, "id") ?? optionalId(threadId); + if (!resumedThreadId) throw new Error("thread/resume response did not include thread.id"); + return { threadId: resumedThreadId, result }; + } + + async function startTurn({ threadId, prompt, model, cwd: turnCwd, approvalPolicy = "never", effort = null } = {}, timeoutMs = 30000) { + const result = await request("turn/start", dropEmpty({ + threadId, + input: codexAppServerTextInput(prompt), + cwd: turnCwd, + approvalPolicy, + model, + effort + }), timeoutMs); + const turnId = extractAppServerString(extractAppServerRecord(result)?.turn, "id"); + if (!turnId) throw new Error("turn/start response did not include turn.id"); + return { turnId, result }; + } + + function setNotificationHandler(handler) { + notificationHandler = typeof handler === "function" ? handler : null; } function close() { + if (closed) return; child.kill("SIGTERM"); } return { + closedPromise, initialize, - listTools, - callTool, + startThread, + resumeThread, + startTurn, + setNotificationHandler, close }; } +async function runAppServerTurn({ + client, + session, + prompt, + workspace, + sandbox, + model, + traceRecorder, + timeoutMs +} = {}) { + const state = createAppServerTurnState({ traceRecorder, session }); + const effectiveMs = effectiveTimeout(timeoutMs); + if (typeof client.setNotificationHandler === "function") { + client.setNotificationHandler((message) => state.handle(message)); + } + try { + let threadId = optionalId(session?.threadId); + if (threadId) { + traceRecorder.append({ + type: "thread", + status: "started", + label: "thread:resume:started", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + threadId, + waitingFor: "thread/resume" + }); + const resumed = await client.resumeThread({ + threadId, + model, + cwd: workspace, + sandbox, + approvalPolicy: "never" + }, effectiveMs); + threadId = optionalId(resumed?.threadId) ?? threadId; + state.setThreadId(threadId); + traceRecorder.append({ + type: "thread", + status: "completed", + label: "thread:resume:completed", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + threadId, + waitingFor: "turn/start" + }); + } else { + traceRecorder.append({ + type: "thread", + status: "started", + label: "thread:start:started", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + waitingFor: "thread/start" + }); + const started = await client.startThread({ + model, + cwd: workspace, + sandbox, + approvalPolicy: "never", + serviceName: "hwlab-cloud-api" + }, effectiveMs); + threadId = optionalId(started?.threadId); + if (!threadId) throw new Error("thread/start response did not include thread.id"); + state.setThreadId(threadId); + traceRecorder.append({ + type: "thread", + status: "completed", + label: "thread:start:completed", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + threadId, + waitingFor: "turn/start" + }); + } + + traceRecorder.append({ + type: "turn", + status: "started", + label: "turn:start:started", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + threadId, + waitingFor: "turn/start" + }); + const turnStart = await client.startTurn({ + threadId, + prompt, + model, + cwd: workspace, + sandbox, + approvalPolicy: "never" + }, effectiveMs); + const turnId = optionalId(turnStart?.turnId); + if (!turnId) throw new Error("turn/start response did not include turn.id"); + state.setTurnId(turnId); + traceRecorder.append({ + type: "turn", + status: "started", + label: "turn:start:completed", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + threadId, + turnId, + waitingFor: "turn/completed" + }); + + const rawResult = typeof client.waitForTurn === "function" + ? await client.waitForTurn(turnId, effectiveMs) + : await state.wait(effectiveMs, client.closedPromise); + return normalizeAppServerTurnResult(rawResult, state.snapshot(), { threadId, turnId }); + } finally { + if (typeof client.setNotificationHandler === "function") { + client.setNotificationHandler(null); + } + } +} + +function createAppServerTurnState({ traceRecorder, session } = {}) { + let threadId = optionalId(session?.threadId); + let turnId = null; + let assistantText = ""; + let finalResponse = ""; + let terminal = null; + let terminalResolve; + const terminalPromise = new Promise((resolve) => { + terminalResolve = resolve; + }); + + function setThreadId(value) { + threadId = optionalId(value) ?? threadId; + } + + function setTurnId(value) { + turnId = optionalId(value) ?? turnId; + } + + function finish(status, error = null, extra = {}) { + if (terminal) return; + terminal = { + terminalStatus: appServerTerminalStatus(status), + terminalError: error ? redactText(error) : null, + threadId, + turnId, + ...extra + }; + terminalResolve(terminal); + } + + function handle(message) { + const method = typeof message?.method === "string" ? message.method : "unknown"; + const params = extractAppServerRecord(message?.params); + const item = extractAppServerRecord(params?.item); + const turn = extractAppServerRecord(params?.turn); + if (method === "thread/started") { + setThreadId(extractAppServerString(extractAppServerRecord(params?.thread), "id")); + traceRecorder?.append({ + type: "thread", + status: "completed", + label: "thread:started", + sessionId: session?.sessionId, + sessionStatus: session?.status, + turn: session?.turn, + threadId, + waitingFor: "turn/start" + }); + return; + } + if (method === "turn/started") { + setTurnId(extractAppServerString(turn, "id")); + traceRecorder?.append({ + type: "turn", + status: "started", + label: "turn:started", + sessionId: session?.sessionId, + sessionStatus: session?.status, + turn: session?.turn, + threadId, + turnId, + waitingFor: "assistant-message" + }); + return; + } + if (method === "item/agentMessage/delta") { + const delta = String(params?.delta ?? ""); + assistantText += delta; + if (delta) { + traceRecorder?.append({ + type: "assistant_message", + status: "chunk", + label: "assistant:chunk", + chunk: delta.slice(0, 400), + sessionId: session?.sessionId, + sessionStatus: session?.status, + turn: session?.turn, + threadId, + turnId, + waitingFor: "turn/completed" + }); + } + return; + } + if (method === "item/completed" && item?.type === "agentMessage") { + if (typeof item.text === "string") finalResponse = item.text; + traceRecorder?.append({ + type: "assistant_message", + status: "completed", + label: "assistant:item_completed", + sessionId: session?.sessionId, + sessionStatus: session?.status, + turn: session?.turn, + threadId, + turnId, + waitingFor: "turn/completed" + }); + return; + } + if (method === "item/commandExecution/outputDelta" || method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") { + traceRecorder?.append({ + type: method.startsWith("item/reasoning/") ? "reasoning" : "tool_call", + status: "output_chunk", + label: method, + outputSummary: String(params?.delta ?? "").slice(0, 400), + sessionId: session?.sessionId, + sessionStatus: session?.status, + turn: session?.turn, + threadId, + turnId, + waitingFor: "turn/completed" + }); + return; + } + if (method === "error") { + const error = extractAppServerRecord(params?.error); + const message = typeof error?.message === "string" ? error.message : "Codex app-server error"; + traceRecorder?.append({ + type: "error", + status: "failed", + label: "app-server:error", + errorCode: "codex_stdio_failed", + message: redactText(message), + sessionId: session?.sessionId, + sessionStatus: session?.status, + turn: session?.turn, + threadId, + turnId, + waitingFor: "user-retry" + }); + finish("failed", message); + return; + } + if (method === "turn/completed") { + const status = appServerTerminalStatus(turn?.status); + const error = extractAppServerRecord(turn?.error); + const message = typeof error?.message === "string" ? error.message : null; + traceRecorder?.append({ + type: "turn", + status, + label: `turn:completed:${status ?? "unknown"}`, + sessionId: session?.sessionId, + sessionStatus: session?.status, + turn: session?.turn, + threadId, + turnId, + errorCode: status === "completed" ? null : "codex_stdio_failed", + message: message ? redactText(message) : undefined, + terminal: status !== "completed" + }); + finish(status, message); + } + } + + async function wait(timeoutMs, closedPromise) { + let timer; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Codex app-server turn timed out after ${timeoutMs}ms`)), timeoutMs); + }); + const candidates = [terminalPromise]; + if (closedPromise && typeof closedPromise.then === "function") { + candidates.push(closedPromise.then((exit) => { + if (terminal) return terminal; + return { + terminalStatus: "failed", + terminalError: "Codex app-server transport closed before turn/completed.", + threadId, + turnId, + appServerExit: exit + }; + })); + } + try { + return await Promise.race([...candidates, timeoutPromise]); + } finally { + clearTimeout(timer); + } + } + + function snapshot() { + return { + threadId, + turnId, + assistantText: redactText(assistantText), + finalResponse: redactText(firstNonEmpty(finalResponse, assistantText)), + terminalStatus: terminal?.terminalStatus ?? null, + terminalError: terminal?.terminalError ?? null + }; + } + + return { + handle, + setThreadId, + setTurnId, + wait, + snapshot + }; +} + +function normalizeAppServerTurnResult(rawResult, state, fallback) { + const raw = extractAppServerRecord(rawResult) ?? {}; + return { + threadId: optionalId(raw.threadId) ?? state.threadId ?? fallback.threadId ?? null, + turnId: optionalId(raw.turnId) ?? state.turnId ?? fallback.turnId ?? null, + assistantText: redactText(firstNonEmpty(raw.assistantText, state.assistantText)), + finalResponse: redactText(firstNonEmpty(raw.finalResponse, raw.content, raw.text, state.finalResponse, state.assistantText)), + terminalStatus: appServerTerminalStatus(raw.terminalStatus ?? raw.status ?? state.terminalStatus), + terminalError: raw.terminalError ? redactText(raw.terminalError) : state.terminalError ?? null, + appServerExit: raw.appServerExit ?? null + }; +} + +function summarizeAppServerTurn(turnResult) { + return [ + turnResult?.threadId ? `threadId=${turnResult.threadId}` : "threadId=not_observed", + turnResult?.turnId ? `turnId=${turnResult.turnId}` : null, + `terminalStatus=${turnResult?.terminalStatus ?? "unknown"}`, + firstNonEmpty(turnResult?.finalResponse, turnResult?.assistantText) ? `assistantChars=${String(firstNonEmpty(turnResult.finalResponse, turnResult.assistantText)).length}` : null + ].filter(Boolean).join(" "); +} + +function codexAppServerTextInput(text) { + return [{ type: "text", text: String(text ?? ""), text_elements: [] }]; +} + +function extractAppServerRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null; +} + +function extractAppServerString(record, key) { + return typeof record?.[key] === "string" && record[key].trim() ? record[key].trim() : null; +} + +function appServerTerminalStatus(value) { + const raw = String(value ?? "").trim().toLowerCase(); + if (raw === "completed" || raw === "complete" || raw === "success" || raw === "succeeded") return "completed"; + if (raw === "canceled" || raw === "cancelled") return "canceled"; + if (raw === "interrupted") return "interrupted"; + if (raw === "timeout" || raw === "timed_out") return "timeout"; + if (raw === "failed" || raw === "error") return "failed"; + return raw ? "failed" : null; +} + export function longLivedSessionGate({ provider, runnerKind, @@ -1330,7 +1819,7 @@ function runnerDescriptor({ workspace, sandbox, session }) { readOnly: false, capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, toolPolicy: { - allowed: ["codex", "codex-reply", "workspace-read", "workspace-write", EXTERNAL_NETWORK_TOOL_NAME], + allowed: ["codex-app-server.thread/start", "codex-app-server.thread/resume", "codex-app-server.turn/start", "workspace-read", "workspace-write", EXTERNAL_NETWORK_TOOL_NAME], blocked: ["secret-read", "kubeconfig-read", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim-without-evidence"] }, runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], @@ -1370,7 +1859,7 @@ function runnerTrace({ traceRecorder, traceId, workspace, sandbox, session, star limitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], startedAt, outputTruncated: Boolean(outputTruncated), - note: "Repo-owned Codex MCP stdio supervisor manages create/reuse/cancel/reap/idle timeout and real-time trace capture; hardware control remains routed through cloud-api/HWLAB API/skill CLI boundaries." + note: "Repo-owned Codex app-server stdio supervisor manages create/reuse/cancel/reap/idle timeout and real-time trace capture; hardware control remains routed through cloud-api/HWLAB API/skill CLI boundaries." }); } @@ -1650,8 +2139,8 @@ async function runExternalNetworkTurn({ capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, providerTrace: { transport: "stdio+controlled-network", - protocol: "mcp-session+http-head", - command: `${availability.command} mcp-server + ${toolName}`, + protocol: `${CODEX_APP_SERVER_PROTOCOL}+http-head`, + command: `${availability.command} app-server --listen stdio:// + ${toolName}`, toolName, targetUrl: safeNetworkUrl(check.url), method: check.method, @@ -2629,7 +3118,7 @@ function supervisorState(env, params, options, enabled) { return { configured, mode: configured ? "repo-owned-node-supervisor" : "disabled", - processModel: "codex mcp-server over stdio", + processModel: "codex app-server --listen stdio://", cancelSupported: true, reapSupported: true, secretMaterialStored: false, @@ -2637,18 +3126,18 @@ function supervisorState(env, params, options, enabled) { }; } -function tokenBoundaryState(env = process.env) { - const present = hasEnvValue(env, "OPENAI_API_KEY") || - hasEnvValue(env, "CODEX_API_KEY") || +function tokenBoundaryState(env = process.env, codexHomeFiles = null) { + const authPresent = codexHomeFiles?.authPresent === true && codexHomeFiles?.authReadable === true; + const present = authPresent || env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY === "configured" || env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY === "present"; return { present, sources: [ - hasEnvValue(env, "OPENAI_API_KEY") ? "OPENAI_API_KEY" : null, - hasEnvValue(env, "CODEX_API_KEY") ? "CODEX_API_KEY" : null, + authPresent ? "CODEX_HOME/auth.json" : null, env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY ? "HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY" : null ].filter(Boolean), + childEnvStripsProviderSecrets: true, secretMaterialRead: false, secretValuesPrinted: false, valuesRedacted: true @@ -2702,6 +3191,32 @@ function directoryStateSync(targetPath, { writableRequired = false } = {}) { } } +function codexHomeFilesStateSync(codexHome) { + const configPath = path.join(codexHome, "config.toml"); + const authPath = path.join(codexHome, "auth.json"); + const configPresent = filePresentSync(configPath); + const authPresent = filePresentSync(authPath); + return { + configPath, + authPath, + configPresent, + configReadable: configPresent && accessSyncBoolean(configPath, fsConstants.R_OK), + authPresent, + authReadable: authPresent && accessSyncBoolean(authPath, fsConstants.R_OK), + mountContract: "writable CODEX_HOME emptyDir with read-only config.toml/auth.json file mounts", + secretMaterialRead: false, + valuesRedacted: true + }; +} + +function filePresentSync(targetPath) { + try { + return existsSync(targetPath) && statSync(targetPath).isFile(); + } catch { + return false; + } +} + function accessSyncBoolean(target, mode) { try { accessSync(target, mode); @@ -2832,37 +3347,37 @@ function codexVersionProbe(command, env = process.env) { } } -function protocolState({ command, binary, supervisor, toolsObserved }) { - const observedTools = Array.isArray(toolsObserved) ? toolsObserved.filter(Boolean) : []; - const missingTools = CODEX_STDIO_REQUIRED_TOOLS.filter((tool) => !observedTools.includes(tool)); - const wired = binary.present === true && supervisor.configured === true && observedTools.length > 0 && missingTools.length === 0; +function protocolState({ command, binary, supervisor, initialized }) { + const protocolInitialized = initialized === true; + const wired = binary.present === true && supervisor.configured === true && protocolInitialized; const probeReady = binary.present === true && supervisor.configured === true; return { transport: "stdio", - command: `${command} mcp-server`, - protocolVersion: MCP_PROTOCOL_VERSION, - adapter: "Model Context Protocol tools/call", - requiredTools: [...CODEX_STDIO_REQUIRED_TOOLS], - toolsObserved: observedTools, - missingTools, + command: `${command} app-server --listen stdio://`, + protocolVersion: CODEX_APP_SERVER_PROTOCOL, + adapter: "Codex app-server JSON-RPC line protocol", + requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS], + initialized: protocolInitialized, + toolsObserved: [], + missingTools: [], wired, probeReady, status: wired ? "wired" : "blocked", blocker: wired ? null : "stdio_protocol_not_wired", summary: wired - ? "Codex stdio MCP protocol is wired and required tools were observed." - : "Codex stdio MCP protocol has not been proven: required tools codex/codex-reply were not observed through repo-owned stdio.", - nextEvidence: "Start `codex mcp-server`, run MCP initialize plus tools/list, and observe codex and codex-reply without printing token material.", + ? "Codex app-server stdio protocol is initialized through the repo-owned JSON-RPC client." + : "Codex app-server stdio protocol has not been proven through initialize/thread/start/turn/start.", + nextEvidence: "Start `codex app-server --listen stdio://`, run initialize, then thread/start or thread/resume plus turn/start without printing token material.", secretMaterialRead: false, valuesRedacted: true }; } -function cachedProbeTools({ command, workspace, codexHome, probe }) { +function cachedProtocolReady({ command, workspace, codexHome, probe }) { if (!probe || probe.ready !== true) return null; if (probe.command !== command || probe.workspace !== workspace || probe.codexHome !== codexHome) return null; if (Number.isFinite(probe.expiresAtMs) && probe.expiresAtMs <= Date.now()) return null; - return Array.isArray(probe.toolsObserved) ? probe.toolsObserved : null; + return probe.initialized === true; } function cachedCommandProbe({ command, workspace, codexHome, probe }) { @@ -2877,7 +3392,8 @@ function protocolProbeSummary({ command, workspace, codexHome, probe }) { return { status: "not_run", ready: false, - toolsObserved: [], + initialized: false, + requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS], secretMaterialRead: false, valuesRedacted: true }; @@ -2885,7 +3401,8 @@ function protocolProbeSummary({ command, workspace, codexHome, probe }) { return { status: probe.status, ready: probe.ready === true, - toolsObserved: Array.isArray(probe.toolsObserved) ? [...probe.toolsObserved] : [], + initialized: probe.initialized === true, + requiredMethods: Array.isArray(probe.requiredMethods) ? [...probe.requiredMethods] : [...CODEX_APP_SERVER_REQUIRED_METHODS], startedAt: probe.startedAt, finishedAt: probe.finishedAt, error: probe.error ?? null, @@ -2985,8 +3502,10 @@ function runtimeContract({ sandbox, codexHome, codexHomeInfo, + codexHomeFiles, tokenBoundary, egress, + childEnvBoundary, commandProbe }) { return { @@ -3007,9 +3526,9 @@ function runtimeContract({ status: protocol.status, wired: protocol.wired, command: protocol.command, - requiredTools: [...protocol.requiredTools], - toolsObserved: [...protocol.toolsObserved], - missingTools: [...protocol.missingTools], + protocolVersion: protocol.protocolVersion, + requiredMethods: [...protocol.requiredMethods], + initialized: protocol.initialized === true, nextEvidence: protocol.nextEvidence }, commandProbe: commandProbe ?? { @@ -3031,7 +3550,7 @@ function runtimeContract({ idleTimeoutMs: lifecycle.idleTimeoutMs }, workspaceMount: workspaceContractState(workspaceInfo, sandbox, workspace), - codexHome: codexHomeContractState(codexHomeInfo, codexHome), + codexHome: codexHomeContractState(codexHomeInfo, codexHome, codexHomeFiles), sandbox, tokenBoundary: { status: tokenBoundary.present ? "present" : "blocked", @@ -3046,6 +3565,7 @@ function runtimeContract({ directPublicOpenAi: egress.directPublicOpenAi, valueRedacted: true }, + childEnvBoundary, cancelReapTraceReadiness: cancelReapTraceState(lifecycle), secretBoundary: { secretsRead: false, @@ -3056,33 +3576,120 @@ function runtimeContract({ }; } -function codexHomeContractState(codexHomeInfo, codexHome) { - const ready = codexHomeInfo.exists && codexHomeInfo.readable && codexHomeInfo.writable; +function codexHomeContractState(codexHomeInfo, codexHome, codexHomeFiles = null) { + const filesReady = codexHomeFiles?.configPresent === true && + codexHomeFiles?.configReadable === true && + codexHomeFiles?.authPresent === true && + codexHomeFiles?.authReadable === true; + const ready = codexHomeInfo.exists && codexHomeInfo.readable && codexHomeInfo.writable && filesReady; return { path: codexHome, status: ready ? "ready" : "blocked", exists: codexHomeInfo.exists, readable: codexHomeInfo.readable, writable: codexHomeInfo.writable, + configToml: { + path: codexHomeFiles?.configPath ?? path.join(codexHome, "config.toml"), + present: codexHomeFiles?.configPresent === true, + readable: codexHomeFiles?.configReadable === true + }, + authJson: { + path: codexHomeFiles?.authPath ?? path.join(codexHome, "auth.json"), + present: codexHomeFiles?.authPresent === true, + readable: codexHomeFiles?.authReadable === true, + valuesRedacted: true + }, writeRequired: true, - blocker: ready ? null : !codexHomeInfo.exists || !codexHomeInfo.readable ? "codex_home_missing" : "codex_home_write_blocked", + blocker: ready + ? null + : !codexHomeInfo.exists || !codexHomeInfo.readable + ? "codex_home_missing" + : codexHomeInfo.writable !== true + ? "codex_home_write_blocked" + : codexHomeFiles?.configPresent !== true || codexHomeFiles?.configReadable !== true + ? "codex_home_config_missing" + : "codex_home_auth_missing", secretMaterialRead: false, valuesRedacted: true }; } function childProcessEnv(env = process.env) { + const noProxy = mergeNoProxy(env.NO_PROXY, env.no_proxy, CODEX_CHILD_NO_PROXY_REQUIRED); return { PATH: Object.hasOwn(env, "PATH") ? env.PATH : process.env.PATH || "/usr/local/bin:/usr/bin:/bin", HOME: env.HOME || process.env.HOME || os.homedir(), CODEX_HOME: resolveCodexHome(env), - ...(env.OPENAI_API_KEY ? { OPENAI_API_KEY: env.OPENAI_API_KEY } : {}), - ...(env.CODEX_API_KEY ? { CODEX_API_KEY: env.CODEX_API_KEY } : {}), - ...(env.HWLAB_CODE_AGENT_OPENAI_BASE_URL ? { OPENAI_BASE_URL: env.HWLAB_CODE_AGENT_OPENAI_BASE_URL } : {}), - ...(env.OPENAI_BASE_URL ? { OPENAI_BASE_URL: env.OPENAI_BASE_URL } : {}) + CODEX_INTERNAL_ORIGINATOR_OVERRIDE: "hwlab_code_agent", + UNIDESK_SKILLS_PATH: firstNonEmpty(env.HWLAB_CODE_AGENT_SKILLS_DIRS, env.UNIDESK_SKILLS_PATH, "/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills"), + NO_PROXY: noProxy, + no_proxy: noProxy, + ...(env.LANG ? { LANG: env.LANG } : {}), + ...(env.LC_ALL ? { LC_ALL: env.LC_ALL } : {}), + ...(env.SSL_CERT_FILE ? { SSL_CERT_FILE: env.SSL_CERT_FILE } : {}), + ...(env.NODE_EXTRA_CA_CERTS ? { NODE_EXTRA_CA_CERTS: env.NODE_EXTRA_CA_CERTS } : {}), + ...(env.GIT_CONFIG_COUNT ? gitConfigEnv(env) : {}) }; } +function gitConfigEnv(env = process.env) { + const count = Number.parseInt(env.GIT_CONFIG_COUNT ?? "", 10); + if (!Number.isInteger(count) || count < 0 || count > 64) return {}; + const result = { GIT_CONFIG_COUNT: String(count) }; + for (let index = 0; index < count; index += 1) { + const keyName = `GIT_CONFIG_KEY_${index}`; + const valueName = `GIT_CONFIG_VALUE_${index}`; + if (typeof env[keyName] === "string") result[keyName] = env[keyName]; + if (typeof env[valueName] === "string") result[valueName] = env[valueName]; + } + return result; +} + +function childProcessEnvState(env = process.env) { + const child = childProcessEnv(env); + const inheritedForbidden = CODEX_CHILD_STRIPPED_ENV_KEYS.filter((key) => hasEnvValue(child, key)); + const sourceForbiddenPresent = CODEX_CHILD_STRIPPED_ENV_KEYS.filter((key) => hasEnvValue(env, key)); + const noProxyEntries = splitNoProxy(child.NO_PROXY); + const requiredMissing = CODEX_CHILD_NO_PROXY_REQUIRED.filter((entry) => !noProxyEntries.includes(entry.toLowerCase())); + return { + status: inheritedForbidden.length === 0 && requiredMissing.length === 0 ? "ready" : "blocked", + forbiddenSourceEnvKeysPresent: sourceForbiddenPresent, + forbiddenEnvPresent: inheritedForbidden.length > 0, + strippedEnvKeys: sourceForbiddenPresent.filter((key) => !inheritedForbidden.includes(key)), + inheritedForbiddenEnvKeys: inheritedForbidden, + noProxy: { + present: Boolean(child.NO_PROXY), + required: [...CODEX_CHILD_NO_PROXY_REQUIRED], + missing: requiredMissing, + merged: noProxyEntries + }, + codeXHome: child.CODEX_HOME, + secretMaterialRead: false, + valuesRedacted: true + }; +} + +function mergeNoProxy(...parts) { + const entries = []; + const seen = new Set(); + for (const entry of parts.flatMap((part) => Array.isArray(part) ? part : splitNoProxy(part))) { + const normalized = String(entry ?? "").trim(); + if (!normalized) continue; + const key = normalized.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + entries.push(normalized); + } + return entries.join(","); +} + +function splitNoProxy(value) { + return String(value ?? "") + .split(/[,;\s]+/u) + .map((item) => item.trim()) + .filter(Boolean); +} + function codexStdioSafety() { return { secretsRead: false, diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index 66bd2902..18da44d7 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -95,7 +95,7 @@ async function runLocalContractSmoke() { validateCodeAgentChatSchema(blocked); assert.equal(blocked.status, "failed"); assert.equal(blocked.provider, "codex-stdio"); - assert.equal(blocked.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(blocked.backend, "hwlab-cloud-api/codex-app-server-stdio"); assert.equal(blocked.error.code, "codex_cli_binary_missing"); assert.equal(Object.hasOwn(blocked, "reply"), false); assert.deepEqual(blocked.toolCalls, []); @@ -120,6 +120,8 @@ async function runLocalContractSmoke() { await mkdir(path.join(stdioSkillsDir, "aaa-stdio-smoke-skill"), { recursive: true }); await mkdir(stdioCodexHome, { recursive: true }); await writeFile(path.join(stdioWorkspace, "README.md"), "stdio smoke workspace\n"); + await writeFile(path.join(stdioCodexHome, "config.toml"), "model = \"gpt-test\"\n"); + await writeFile(path.join(stdioCodexHome, "auth.json"), "{\"auth\":\"redacted-test-placeholder\"}\n"); await writeFile(path.join(stdioSkillsDir, "aaa-stdio-smoke-skill", "SKILL.md"), [ "---", "name: aaa-stdio-smoke-skill", @@ -134,24 +136,7 @@ async function runLocalContractSmoke() { const rpcCalls = []; const stdioManager = createCodexStdioSessionManager({ idFactory: () => "ses_code_agent_chat_smoke_stdio", - createRpcClient: async () => ({ - async initialize() { - return { tools: ["codex", "codex-reply"] }; - }, - async listTools() { - return ["codex", "codex-reply"]; - }, - async callTool(name, toolArgs) { - rpcCalls.push({ name, toolArgs }); - return { - structuredContent: { - threadId: "thread_code_agent_chat_smoke_stdio", - content: `${name} fixture reply for ${toolArgs?.threadId ?? "new-thread"}` - } - }; - }, - close() {} - }) + createRpcClient: async () => createFakeAppServerClient({ rpcCalls }) }); const stdioEnv = { PATH: process.env.PATH, @@ -184,10 +169,10 @@ async function runLocalContractSmoke() { validateCodeAgentChatSchema(stdioPwd); assert.equal(stdioPwd.status, "completed"); assert.equal(stdioPwd.provider, "codex-stdio"); - assert.equal(stdioPwd.backend, "hwlab-cloud-api/codex-mcp-stdio"); - assert.equal(stdioPwd.runner.kind, "codex-mcp-stdio-runner"); - assert.equal(stdioPwd.sessionMode, "codex-mcp-stdio-long-lived"); - assert.equal(stdioPwd.implementationType, "repo-owned-codex-mcp-stdio-session"); + assert.equal(stdioPwd.backend, "hwlab-cloud-api/codex-app-server-stdio"); + assert.equal(stdioPwd.runner.kind, "codex-app-server-stdio-runner"); + assert.equal(stdioPwd.sessionMode, "codex-app-server-stdio-long-lived"); + assert.equal(stdioPwd.implementationType, "repo-owned-codex-app-server-stdio-session"); assert.equal(stdioPwd.capabilityLevel, "long-lived-codex-stdio-session"); assert.equal(stdioPwd.workspace, stdioWorkspace); assert.equal(stdioPwd.sandbox, "workspace-write"); @@ -205,22 +190,24 @@ async function runLocalContractSmoke() { assert.equal(stdioPwd.codexStdioFeasibility.runtimeContract.codexHome.status, "ready"); assert.equal(stdioPwd.longLivedSessionGate.status, "pass"); assert.equal(stdioPwd.longLivedSessionGate.pass, true); - assert.equal(stdioPwd.providerTrace.toolName, "codex"); + assert.equal(stdioPwd.providerTrace.toolName, "codex-app-server.thread/start+turn/start"); assert.equal(stdioPwd.providerTrace.threadId, "thread_code_agent_chat_smoke_stdio"); assert.equal(Object.hasOwn(stdioPwd.providerTrace, "sidecarOnly"), false); - assert.equal(rpcCalls[0].name, "codex"); - assert.match(rpcCalls[0].toolArgs.prompt, /列出可以使用的skill/u); - assert.ok(stdioPwd.toolCalls.some((call) => call.name === "codex" && call.status === "completed")); + const firstTurnCalls = rpcCalls.filter((call) => call.method !== "initialize"); + assert.equal(firstTurnCalls[0].method, "thread/start"); + assert.equal(firstTurnCalls[1].method, "turn/start"); + assert.match(firstTurnCalls[1].args.prompt, /列出可以使用的skill/u); + assert.ok(stdioPwd.toolCalls.some((call) => call.name === "codex-app-server.thread/start+turn/start" && call.status === "completed")); assert.ok(stdioPwd.toolCalls.some((call) => call.name === "skills.discover" && call.status === "completed")); assert.equal(stdioPwd.skills.status, "ready"); assert.ok(stdioPwd.skills.items.some((skill) => skill.name === "aaa-stdio-smoke-skill")); - assert.equal(stdioPwd.runnerTrace.runnerKind, "codex-mcp-stdio-runner"); - assert.equal(stdioPwd.runnerTrace.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(stdioPwd.runnerTrace.runnerKind, "codex-app-server-stdio-runner"); + assert.equal(stdioPwd.runnerTrace.sessionMode, "codex-app-server-stdio-long-lived"); assert.ok(runnerTraceLabels(stdioPwd).includes("session:created")); assert.ok(runnerTraceLabels(stdioPwd).includes("prompt:sent")); - assert.ok(runnerTraceLabels(stdioPwd).includes("tool:codex:started")); - assert.ok(runnerTraceLabels(stdioPwd).includes("tool:codex:output_chunk")); - assert.ok(runnerTraceLabels(stdioPwd).includes("tool:codex:completed")); + assert.ok(runnerTraceLabels(stdioPwd).includes("tool:codex-app-server.thread/start+turn/start:started")); + assert.ok(runnerTraceLabels(stdioPwd).includes("tool:codex-app-server.thread/start+turn/start:output_chunk")); + assert.ok(runnerTraceLabels(stdioPwd).includes("tool:codex-app-server.thread/start+turn/start:completed")); assert.ok(runnerTraceLabels(stdioPwd).includes("assistant:chunk")); assert.ok(runnerTraceLabels(stdioPwd).includes("assistant:completed")); assert.equal(classifyCodexRunnerCapability(stdioPwd, { httpStatus: 200 }).capabilityPass, true); @@ -247,12 +234,12 @@ async function runLocalContractSmoke() { assert.equal(stdioSecondTurn.session.threadId, "thread_code_agent_chat_smoke_stdio"); assert.equal(stdioSecondTurn.sessionReuse.reused, true); assert.equal(stdioSecondTurn.sessionReuse.turn, 2); - assert.equal(stdioSecondTurn.providerTrace.toolName, "codex-reply"); + assert.equal(stdioSecondTurn.providerTrace.toolName, "codex-app-server.thread/resume+turn/start"); assert.equal(Object.hasOwn(stdioSecondTurn.providerTrace, "sidecarOnly"), false); - assert.ok(stdioSecondTurn.toolCalls.some((call) => call.name === "codex-reply" && call.status === "completed")); + assert.ok(stdioSecondTurn.toolCalls.some((call) => call.name === "codex-app-server.thread/resume+turn/start" && call.status === "completed")); assert.ok(runnerTraceLabels(stdioSecondTurn).includes("session:reused")); assert.ok(runnerTraceLabels(stdioSecondTurn).includes("prompt:sent")); - assert.ok(runnerTraceLabels(stdioSecondTurn).includes("tool:codex-reply:completed")); + assert.ok(runnerTraceLabels(stdioSecondTurn).includes("tool:codex-app-server.thread/resume+turn/start:completed")); assert.equal(classifyCodexRunnerCapability(stdioSecondTurn, { httpStatus: 200 }).capabilityPass, true); assert.equal(JSON.stringify(stdioSecondTurn).includes("test-openai-key-material"), false); logOk("codex stdio runner passes long-lived workspace-write session capability"); @@ -272,10 +259,10 @@ async function runLocalContractSmoke() { traceId: "trc_code-agent-chat-stdio-http", provider: "codex-stdio", model: "gpt-5.5", - backend: "hwlab-cloud-api/codex-mcp-stdio", - runner: { kind: "codex-mcp-stdio-runner", codexStdio: true, writeCapable: true, durableSession: true }, - sessionMode: "codex-mcp-stdio-long-lived", - implementationType: "repo-owned-codex-mcp-stdio-session", + backend: "hwlab-cloud-api/codex-app-server-stdio", + runner: { kind: "codex-app-server-stdio-runner", codexStdio: true, writeCapable: true, durableSession: true }, + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session", capabilityLevel: "long-lived-codex-stdio-session", reply: { messageId: "msg_code-agent-chat-stdio-http", @@ -443,6 +430,42 @@ async function createFakeCodexCommand() { return { root, command }; } +function createFakeAppServerClient({ rpcCalls }) { + let notificationHandler = null; + let turn = 0; + return { + async initialize() { + rpcCalls.push({ method: "initialize" }); + return { initialized: true }; + }, + setNotificationHandler(handler) { + notificationHandler = typeof handler === "function" ? handler : null; + }, + async startThread(args) { + rpcCalls.push({ method: "thread/start", args }); + notificationHandler?.({ method: "thread/started", params: { thread: { id: "thread_code_agent_chat_smoke_stdio" } } }); + return { threadId: "thread_code_agent_chat_smoke_stdio" }; + }, + async resumeThread(args) { + rpcCalls.push({ method: "thread/resume", args }); + notificationHandler?.({ method: "thread/started", params: { thread: { id: args.threadId } } }); + return { threadId: args.threadId }; + }, + async startTurn(args) { + rpcCalls.push({ method: "turn/start", args }); + turn += 1; + const turnId = `turn_code_agent_chat_smoke_${turn}`; + const text = `codex-app-server fixture reply for ${args.threadId ?? "new-thread"}`; + notificationHandler?.({ method: "turn/started", params: { turn: { id: turnId } } }); + notificationHandler?.({ method: "item/agentMessage/delta", params: { itemId: `item_${turn}`, delta: text } }); + notificationHandler?.({ method: "item/completed", params: { item: { id: `item_${turn}`, type: "agentMessage", text } } }); + notificationHandler?.({ method: "turn/completed", params: { turn: { id: turnId, status: "completed" } } }); + return { turnId }; + }, + close() {} + }; +} + function agentChatEndpoint(value) { const url = new URL(value); if (url.pathname.endsWith("/v1/agent/chat")) return url; diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 2d43efe2..5e85737c 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -120,12 +120,12 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { const missing = []; if (payload?.status !== "completed") missing.push("completed status"); if (!trustedRunnerProviders.has(provider)) missing.push("runner provider"); - if (runnerKind !== "codex-mcp-stdio-runner") missing.push("runner.kind=codex-mcp-stdio-runner"); + if (runnerKind !== "codex-app-server-stdio-runner") missing.push("runner.kind=codex-app-server-stdio-runner"); if (capabilityLevel !== "long-lived-codex-stdio-session") missing.push("capabilityLevel=long-lived-codex-stdio-session"); if (!workspace) missing.push("workspace"); if (sandbox !== "workspace-write") missing.push("sandbox=workspace-write"); - if (sessionMode !== "codex-mcp-stdio-long-lived") missing.push("sessionMode=codex-mcp-stdio-long-lived"); - if (implementationType !== "repo-owned-codex-mcp-stdio-session") missing.push("implementationType=repo-owned-codex-mcp-stdio-session"); + if (sessionMode !== "codex-app-server-stdio-long-lived") missing.push("sessionMode=codex-app-server-stdio-long-lived"); + if (implementationType !== "repo-owned-codex-app-server-stdio-session") missing.push("implementationType=repo-owned-codex-app-server-stdio-session"); if (!session) missing.push("session"); if (session && !["idle", "ready", "busy"].includes(session.status)) missing.push("session.status active/idle"); if (session && typeof session.idleTimeoutMs !== "number") missing.push("session.idleTimeoutMs"); diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index d61f921e..00741291 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -401,6 +401,8 @@ function validateCloudApiCodeAgentSource(ctx, env) { expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "workspace-write", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_SANDBOX", "cloud API Codex stdio sandbox contract"); expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_COMMAND, "/app/node_modules/.bin/codex", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_COMMAND", "cloud API Codex command contract"); expectEqual(ctx, env.CODEX_HOME, "/codex-home", "$.services.hwlab-cloud-api.env.CODEX_HOME", "cloud API CODEX_HOME contract"); + expectNoProxyIncludes(ctx, env.NO_PROXY, contract.noProxyRequired, "$.services.hwlab-cloud-api.env.NO_PROXY"); + expectNoProxyIncludes(ctx, env.no_proxy, contract.noProxyRequired, "$.services.hwlab-cloud-api.env.no_proxy"); expect( ctx, env.HWLAB_CODE_AGENT_OPENAI_BASE_URL !== contract.egress.forbiddenDirectBaseUrl, @@ -570,6 +572,7 @@ function validateCloudApiCodeAgentArtifacts(ctx, workloads) { const secretRef = contract.secretRefs[0]; const container = mapByServiceId(listItems(workloads)).get("hwlab-cloud-api")?.spec?.template?.spec?.containers?.[0]; const env = new Map((container?.env ?? []).map((entry) => [entry.name, entry])); + const volumes = mapByServiceId(listItems(workloads)).get("hwlab-cloud-api")?.spec?.template?.spec?.volumes ?? []; expectEqual(ctx, env.get("HWLAB_CODE_AGENT_PROVIDER")?.value, contract.codeAgentProvider, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_PROVIDER", "cloud API workload Code Agent provider"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_MODEL")?.value, contract.model, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_MODEL", "cloud API workload Code Agent model"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_TIMEOUT_MS")?.value, codeAgentBackendTimeoutMs, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_TIMEOUT_MS", "cloud API workload Code Agent hard timeout budget"); @@ -594,6 +597,9 @@ function validateCloudApiCodeAgentArtifacts(ctx, workloads) { expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_SANDBOX")?.value, "workspace-write", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_SANDBOX", "cloud API workload Codex stdio sandbox contract"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_COMMAND")?.value, "/app/node_modules/.bin/codex", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_COMMAND", "cloud API workload Codex command contract"); expectEqual(ctx, env.get("CODEX_HOME")?.value, "/codex-home", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.CODEX_HOME", "cloud API workload CODEX_HOME contract"); + expectNoProxyIncludes(ctx, env.get("NO_PROXY")?.value, contract.noProxyRequired, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.NO_PROXY"); + expectNoProxyIncludes(ctx, env.get("no_proxy")?.value, contract.noProxyRequired, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.no_proxy"); + validateCodexHomeMountArtifacts(ctx, container?.volumeMounts ?? [], volumes, contract); expect( ctx, env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value !== contract.egress.forbiddenDirectBaseUrl, @@ -606,6 +612,72 @@ function validateCloudApiCodeAgentArtifacts(ctx, workloads) { expectEqual(ctx, env.get(secretRef.env)?.valueFrom?.secretKeyRef?.key, secretRef.secretKey, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.OPENAI_API_KEY.secretKeyRef.key", "cloud API workload Code Agent Secret key"); } +function expectNoProxyIncludes(ctx, value, required, path, label = "NO_PROXY contract") { + const entries = new Set(String(value ?? "") + .split(/[,;\s]+/u) + .map((item) => item.trim().toLowerCase()) + .filter(Boolean)); + for (const item of required) { + expect(ctx, entries.has(String(item).toLowerCase()), "missing_no_proxy_entry", path, `${label} includes ${item}`, { expected: item, actual: value }); + } +} + +function validateCodexHomeMountArtifacts(ctx, volumeMounts, volumes, contract) { + expect( + ctx, + volumeMounts.some((entry) => entry.name === "hwlab-code-agent-codex-home" && entry.mountPath === contract.codexHome.path), + "codex_home_mount_missing", + "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.volumeMounts.hwlab-code-agent-codex-home", + "cloud API workload CODEX_HOME writable mount" + ); + expect( + ctx, + volumeMounts.some((entry) => + entry.name === "hwlab-code-agent-codex-config" && + entry.mountPath === `${contract.codexHome.path}/${contract.codexHome.configKey}` && + entry.subPath === contract.codexHome.configKey && + entry.readOnly === true + ), + "codex_config_mount_missing", + "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.volumeMounts.hwlab-code-agent-codex-config", + "cloud API workload Codex config.toml read-only mount" + ); + expect( + ctx, + volumeMounts.some((entry) => + entry.name === "hwlab-code-agent-codex-auth" && + entry.mountPath === `${contract.codexHome.path}/${contract.codexHome.authKey}` && + entry.subPath === contract.codexHome.authKey && + entry.readOnly === true + ), + "codex_auth_mount_missing", + "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.volumeMounts.hwlab-code-agent-codex-auth", + "cloud API workload Codex auth.json read-only mount" + ); + expect( + ctx, + volumes.some((entry) => + entry.name === "hwlab-code-agent-codex-config" && + entry.configMap?.name === contract.codexHome.configMapName && + (entry.configMap.items ?? []).some((item) => item.key === contract.codexHome.configKey && item.path === contract.codexHome.configKey) + ), + "codex_config_volume_missing", + "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.volumes.hwlab-code-agent-codex-config", + "cloud API workload Codex config ConfigMap ref" + ); + expect( + ctx, + volumes.some((entry) => + entry.name === "hwlab-code-agent-codex-auth" && + entry.secret?.secretName === contract.codexHome.secretName && + (entry.secret.items ?? []).some((item) => item.key === contract.codexHome.authKey && item.path === contract.codexHome.authKey) + ), + "codex_auth_volume_missing", + "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.volumes.hwlab-code-agent-codex-auth", + "cloud API workload Codex auth Secret ref" + ); +} + function validateCodeAgentProxyTimeoutArtifacts(ctx, workloads) { const workloadItems = mapByServiceId(listItems(workloads)); const cloudWebEnv = new Map((workloadItems.get("hwlab-cloud-web")?.spec?.template?.spec?.containers?.[0]?.env ?? []).map((entry) => [entry.name, entry.value])); diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index 0407cbde..dec3e976 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -160,7 +160,11 @@ assert.equal(deployServicesById.has("hwlab-agent-skills"), true); const cloudApi = deployServicesById.get("hwlab-cloud-api"); const patchPanel = deployServicesById.get("hwlab-patch-panel"); +const cloudApiWorkload = getWorkload(k8sWorkloads, "hwlab-cloud-api"); +const cloudApiContainer = cloudApiWorkload?.spec?.template?.spec?.containers?.find((entry) => entry.name === "hwlab-cloud-api"); const cloudApiWorkloadEnv = getWorkloadEnv(k8sWorkloads, "hwlab-cloud-api"); +cloudApiWorkloadEnv.__volumeMounts = cloudApiContainer?.volumeMounts ?? []; +cloudApiWorkloadEnv.__volumes = cloudApiWorkload?.spec?.template?.spec?.volumes ?? []; const patchPanelWorkloadEnv = getWorkloadEnv(k8sWorkloads, "hwlab-patch-panel"); assert.equal(deployManifest.health.path, "/health/live", "deploy health source path"); assert.equal(deployManifest.publicEndpoints.frontend.url, "http://74.48.78.17:16666", "deploy frontend endpoint"); @@ -219,6 +223,8 @@ assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, "/workspace/hwlab", assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "workspace-write", "cloud-api Codex stdio sandbox contract"); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_COMMAND, "/app/node_modules/.bin/codex", "cloud-api Codex command contract"); assert.equal(cloudApi.env.CODEX_HOME, "/codex-home", "cloud-api CODEX_HOME contract"); +assertNoProxyIncludes(cloudApi.env.NO_PROXY, DEV_CODE_AGENT_PROVIDER_CONTRACT.noProxyRequired, "cloud-api NO_PROXY contract"); +assertNoProxyIncludes(cloudApi.env.no_proxy, DEV_CODE_AGENT_PROVIDER_CONTRACT.noProxyRequired, "cloud-api no_proxy contract"); assert.notEqual( cloudApi.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL, DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.forbiddenDirectBaseUrl, @@ -237,6 +243,12 @@ function listItems(document) { } function getWorkloadEnv(workloads, serviceId) { + const item = getWorkload(workloads, serviceId); + const container = item?.spec?.template?.spec?.containers?.find((entry) => entry.name === serviceId); + return Object.fromEntries((container?.env ?? []).map((entry) => [entry.name, entry])); +} + +function getWorkload(workloads, serviceId) { for (const item of listItems(workloads)) { const itemServiceId = item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] || @@ -245,10 +257,9 @@ function getWorkloadEnv(workloads, serviceId) { if (itemServiceId !== serviceId) { continue; } - const container = item?.spec?.template?.spec?.containers?.find((entry) => entry.name === serviceId); - return Object.fromEntries((container?.env ?? []).map((entry) => [entry.name, entry])); + return item; } - return {}; + return null; } function assertCloudApiDbOptionalAlias(services) { @@ -284,6 +295,9 @@ function assertCodeAgentProviderWorkloadContract(env) { assert.equal(env.HWLAB_CODE_AGENT_CODEX_SANDBOX?.value, "workspace-write", "cloud-api workload Codex stdio sandbox contract"); assert.equal(env.HWLAB_CODE_AGENT_CODEX_COMMAND?.value, "/app/node_modules/.bin/codex", "cloud-api workload Codex command contract"); assert.equal(env.CODEX_HOME?.value, "/codex-home", "cloud-api workload CODEX_HOME contract"); + assertNoProxyIncludes(env.NO_PROXY?.value, contract.noProxyRequired, "cloud-api workload NO_PROXY contract"); + assertNoProxyIncludes(env.no_proxy?.value, contract.noProxyRequired, "cloud-api workload no_proxy contract"); + assertCodexHomeMountContract(env, contract); assert.notEqual( env.HWLAB_CODE_AGENT_OPENAI_BASE_URL?.value, contract.egress.forbiddenDirectBaseUrl, @@ -293,6 +307,59 @@ function assertCodeAgentProviderWorkloadContract(env) { assert.equal(apiKey?.key, secretRef.secretKey, "cloud-api workload Code Agent OpenAI Secret key"); } +function assertNoProxyIncludes(value, required, label) { + const entries = new Set(String(value ?? "") + .split(/[,;\s]+/u) + .map((item) => item.trim().toLowerCase()) + .filter(Boolean)); + for (const item of required) { + assert.equal(entries.has(String(item).toLowerCase()), true, `${label} includes ${item}`); + } +} + +function assertCodexHomeMountContract(env, contract) { + const volumeMounts = env.__volumeMounts ?? []; + const volumes = env.__volumes ?? []; + assert.ok( + volumeMounts.some((entry) => entry.name === "hwlab-code-agent-codex-home" && entry.mountPath === contract.codexHome.path), + "cloud-api workload CODEX_HOME writable mount" + ); + assert.ok( + volumeMounts.some((entry) => + entry.name === "hwlab-code-agent-codex-config" && + entry.mountPath === `${contract.codexHome.path}/${contract.codexHome.configKey}` && + entry.subPath === contract.codexHome.configKey && + entry.readOnly === true + ), + "cloud-api workload Codex config.toml read-only mount" + ); + assert.ok( + volumeMounts.some((entry) => + entry.name === "hwlab-code-agent-codex-auth" && + entry.mountPath === `${contract.codexHome.path}/${contract.codexHome.authKey}` && + entry.subPath === contract.codexHome.authKey && + entry.readOnly === true + ), + "cloud-api workload Codex auth.json read-only mount" + ); + assert.ok( + volumes.some((entry) => + entry.name === "hwlab-code-agent-codex-config" && + entry.configMap?.name === contract.codexHome.configMapName && + (entry.configMap.items ?? []).some((item) => item.key === contract.codexHome.configKey && item.path === contract.codexHome.configKey) + ), + "cloud-api workload Codex config ConfigMap ref" + ); + assert.ok( + volumes.some((entry) => + entry.name === "hwlab-code-agent-codex-auth" && + entry.secret?.secretName === contract.codexHome.secretName && + (entry.secret.items ?? []).some((item) => item.key === contract.codexHome.authKey && item.path === contract.codexHome.authKey) + ), + "cloud-api workload Codex auth Secret ref" + ); +} + function assertM3PatchPanelDeployContract(manifestService, workloadEnv) { const route = { fromResourceId: "res_boxsimu_1",