diff --git a/deploy/deploy.json b/deploy/deploy.json index 4db6276d..f03b9fb2 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -208,8 +208,11 @@ "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", "HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED": "1", "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR": "repo-owned", + "HWLAB_CODE_AGENT_WORKSPACE": "/workspace/hwlab", "HWLAB_CODE_AGENT_CODEX_WORKSPACE": "/workspace/hwlab", "HWLAB_CODE_AGENT_CODEX_SANDBOX": "workspace-write", + "HWLAB_CODE_AGENT_CODEX_COMMAND": "/app/node_modules/.bin/codex", + "CODEX_HOME": "/codex-home", "OPENAI_API_KEY": "secretRef:hwlab-code-agent-provider/openai-api-key" } }, diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index ef09f74f..eb7a396b 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -430,6 +430,10 @@ "type": "string", "const": "repo-owned" }, + "HWLAB_CODE_AGENT_WORKSPACE": { + "type": "string", + "const": "/workspace/hwlab" + }, "HWLAB_CODE_AGENT_CODEX_WORKSPACE": { "type": "string", "const": "/workspace/hwlab" @@ -438,6 +442,14 @@ "type": "string", "const": "workspace-write" }, + "HWLAB_CODE_AGENT_CODEX_COMMAND": { + "type": "string", + "const": "/app/node_modules/.bin/codex" + }, + "CODEX_HOME": { + "type": "string", + "const": "/codex-home" + }, "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 69b19434..99c21b3e 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -129,6 +129,10 @@ "name": "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", "value": "repo-owned" }, + { + "name": "HWLAB_CODE_AGENT_WORKSPACE", + "value": "/workspace/hwlab" + }, { "name": "HWLAB_CODE_AGENT_CODEX_WORKSPACE", "value": "/workspace/hwlab" @@ -137,6 +141,14 @@ "name": "HWLAB_CODE_AGENT_CODEX_SANDBOX", "value": "workspace-write" }, + { + "name": "HWLAB_CODE_AGENT_CODEX_COMMAND", + "value": "/app/node_modules/.bin/codex" + }, + { + "name": "CODEX_HOME", + "value": "/codex-home" + }, { "name": "OPENAI_API_KEY", "valueFrom": { diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 13da9b90..76d47655 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs"; @@ -929,6 +932,53 @@ test("Codex stdio manager reports concrete blockers without falling back to read assert.equal(Object.hasOwn(blocked, "reply"), false); }); +test("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and version-probed command", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-stdio-workspace-")); + const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-codex-home-")); + try { + const manager = createCodexStdioSessionManager(); + const codexCommand = path.join(process.cwd(), "node_modules", ".bin", "codex"); + const availability = manager.describe({ + env: { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_CODEX_COMMAND: codexCommand, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, + HWLAB_CODE_AGENT_CODEX_SANDBOX: "workspace-write", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + } + }); + + assert.equal(availability.command, codexCommand); + assert.equal(availability.binary.present, true); + assert.equal(availability.binary.versionDetected, true); + assert.equal(availability.workspace, workspace); + assert.equal(availability.workspaceState.exists, true); + assert.equal(availability.workspaceState.readable, true); + assert.equal(availability.workspaceState.writable, true); + assert.equal(availability.runtimeContract.workspaceMount.status, "ready"); + assert.equal(availability.codexHome, codexHome); + assert.equal(availability.codexHomeState.exists, true); + assert.equal(availability.codexHomeState.readable, true); + assert.equal(availability.codexHomeState.writable, true); + assert.equal(availability.runtimeContract.codexHome.status, "ready"); + assert.equal(availability.startupReady, true); + assert.equal(availability.canStartLongLivedCodexStdio, true); + assert.equal(availability.ready, false); + assert.ok(availability.blockerCodes.includes("stdio_protocol_not_wired")); + assert.equal(availability.blockerCodes.includes("workspace_mount_missing"), false); + assert.equal(availability.blockerCodes.includes("workspace_write_boundary_blocked"), false); + assert.equal(availability.blockerCodes.includes("codex_home_missing"), false); + assert.equal(availability.blockerCodes.includes("codex_home_write_blocked"), false); + } finally { + await rm(workspace, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); + } +}); + test("repo-owned Codex stdio manager creates and reuses long-lived sessions with trace evidence", async () => { const calls = []; const manager = createCodexStdioSessionManager({ @@ -955,6 +1005,7 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with const env = { PATH: process.env.PATH, OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: process.cwd(), HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", @@ -1025,6 +1076,7 @@ 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(), HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", @@ -1081,6 +1133,7 @@ test("Codex stdio protocol missing tools is a structured blocker, not a passed s const env = { PATH: process.env.PATH, OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: process.cwd(), HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", @@ -1110,6 +1163,6 @@ test("Codex stdio protocol missing tools is a structured blocker, not a passed s 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.availability.codexStdio.protocol.toolsObserved, ["codex"]); + assert.deepEqual(payload.codexStdioFeasibility.protocol.toolsObserved, ["codex"]); assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false); }); diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index da4462bf..497569db 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -50,12 +50,14 @@ export function createCodexStdioSessionManager(options = {}) { 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 egress = egressState(env); const protocol = protocolState({ command, binary, supervisor, - toolsObserved: rpcToolNames + toolsObserved: params.toolsObserved ?? rpcToolNames }); const lifecycle = lifecycleState({ supervisor, @@ -107,6 +109,20 @@ export function createCodexStdioSessionManager(options = {}) { summary: `Configured Codex workspace is not writable for workspace-write sandbox: ${workspace}.` }); } + if (!codexHomeInfo.exists || !codexHomeInfo.readable) { + blockers.push({ + code: "codex_home_missing", + sourceIssue: "pikasTech/HWLAB#377", + summary: `Configured CODEX_HOME is not present/readable: ${codexHome}.` + }); + } + if (codexHomeInfo.writable !== true) { + blockers.push({ + code: "codex_home_write_blocked", + sourceIssue: "pikasTech/HWLAB#377", + summary: `Configured CODEX_HOME is not writable for Codex session state: ${codexHome}.` + }); + } if (!tokenBoundary.present) { blockers.push({ code: "provider_token_boundary", @@ -123,18 +139,23 @@ export function createCodexStdioSessionManager(options = {}) { } const ready = blockers.length === 0; + const startupBlockers = blockers.filter((blocker) => blocker.code !== "stdio_protocol_not_wired"); + const startupReady = startupBlockers.length === 0 && protocol.probeReady === true; return { kind: CODEX_STDIO_RUNNER_KIND, provider: CODEX_STDIO_PROVIDER, backend: CODEX_STDIO_BACKEND, status: ready ? "feasible" : "blocked", ready, - canStartLongLivedCodexStdio: ready, + startupReady, + canStartLongLivedCodexStdio: startupReady, command, binary, binaryOnPath, workspace, workspaceState: workspaceInfo, + codexHome, + codexHomeState: codexHomeInfo, sandbox, enabled, supervisor, @@ -154,6 +175,8 @@ export function createCodexStdioSessionManager(options = {}) { workspaceInfo, workspace, sandbox, + codexHome, + codexHomeInfo, tokenBoundary, egress }), @@ -305,6 +328,7 @@ export function createCodexStdioSessionManager(options = {}) { } }; } catch (error) { + closeRpcClient(); session = failSession(session.sessionId, { now, traceId, @@ -352,10 +376,7 @@ export function createCodexStdioSessionManager(options = {}) { session.currentTraceId = null; session.statusReason = params.reason ?? "cancelled"; if (rpcClient && typeof rpcClient.close === "function") { - rpcClient.close(); - rpcClient = null; - rpcStartedAt = null; - rpcToolNames = null; + closeRpcClient(); } return publicSession(session, { conversationId: params.conversationId, reused: true }); } @@ -371,11 +392,8 @@ export function createCodexStdioSessionManager(options = {}) { session.currentTraceId = null; reaped.push(session.sessionId); } - if (sessionsBusyCount() === 0 && reaped.length > 0 && rpcClient && typeof rpcClient.close === "function") { - rpcClient.close(); - rpcClient = null; - rpcStartedAt = null; - rpcToolNames = null; + if (sessionsBusyCount() === 0 && reaped.length > 0) { + closeRpcClient(); } return { reapedCount: reaped.length, @@ -391,7 +409,13 @@ export function createCodexStdioSessionManager(options = {}) { function clear() { sessions.clear(); conversations.clear(); - if (rpcClient && typeof rpcClient.close === "function") rpcClient.close(); + closeRpcClient(); + } + + function closeRpcClient() { + if (rpcClient && typeof rpcClient.close === "function") { + rpcClient.close(); + } rpcClient = null; rpcStartedAt = null; rpcToolNames = null; @@ -415,12 +439,15 @@ export function createCodexStdioSessionManager(options = {}) { } 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 + command: availability.command, + toolsObserved: observedTools }), missingTools }); @@ -982,6 +1009,14 @@ function resolveCodexSandbox(env = process.env, options = {}) { return ["read-only", "workspace-write"].includes(value) ? value : CODEX_STDIO_SANDBOX; } +function resolveCodexHome(env = process.env) { + return path.resolve(firstNonEmpty( + env.CODEX_HOME, + env.HWLAB_CODE_AGENT_CODEX_HOME, + path.join(env.HOME || process.env.HOME || os.homedir(), ".codex") + )); +} + function codexStdioEnabled(env, params, options) { if (params.enabled === true || options.enabled === true) return true; return env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED === "1" || @@ -1008,7 +1043,6 @@ function supervisorState(env, params, options, enabled) { function tokenBoundaryState(env = process.env) { const present = hasEnvValue(env, "OPENAI_API_KEY") || hasEnvValue(env, "CODEX_API_KEY") || - hasEnvValue(env, "CODEX_HOME") || env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY === "configured" || env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY === "present"; return { @@ -1016,7 +1050,6 @@ function tokenBoundaryState(env = process.env) { sources: [ hasEnvValue(env, "OPENAI_API_KEY") ? "OPENAI_API_KEY" : null, hasEnvValue(env, "CODEX_API_KEY") ? "CODEX_API_KEY" : null, - hasEnvValue(env, "CODEX_HOME") ? "CODEX_HOME" : null, env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY ? "HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY" : null ].filter(Boolean), secretMaterialRead: false, @@ -1053,6 +1086,25 @@ function workspaceStateSync(workspace, sandbox) { } } +function directoryStateSync(targetPath, { writableRequired = false } = {}) { + const state = { + path: targetPath, + exists: false, + readable: false, + writable: false, + writeRequired: writableRequired + }; + try { + state.exists = existsSync(targetPath); + if (!state.exists) return state; + state.readable = accessSyncBoolean(targetPath, fsConstants.R_OK); + state.writable = accessSyncBoolean(targetPath, fsConstants.W_OK); + return state; + } catch { + return state; + } +} + function accessSyncBoolean(target, mode) { try { accessSync(target, mode); @@ -1218,6 +1270,8 @@ function runtimeContract({ workspaceInfo, workspace, sandbox, + codexHome, + codexHomeInfo, tokenBoundary, egress }) { @@ -1253,6 +1307,7 @@ function runtimeContract({ idleTimeoutMs: lifecycle.idleTimeoutMs }, workspaceMount: workspaceContractState(workspaceInfo, sandbox, workspace), + codexHome: codexHomeContractState(codexHomeInfo, codexHome), sandbox, tokenBoundary: { status: tokenBoundary.present ? "present" : "blocked", @@ -1277,11 +1332,26 @@ function runtimeContract({ }; } +function codexHomeContractState(codexHomeInfo, codexHome) { + const ready = codexHomeInfo.exists && codexHomeInfo.readable && codexHomeInfo.writable; + return { + path: codexHome, + status: ready ? "ready" : "blocked", + exists: codexHomeInfo.exists, + readable: codexHomeInfo.readable, + writable: codexHomeInfo.writable, + writeRequired: true, + blocker: ready ? null : !codexHomeInfo.exists || !codexHomeInfo.readable ? "codex_home_missing" : "codex_home_write_blocked", + secretMaterialRead: false, + valuesRedacted: true + }; +} + function childProcessEnv(env = process.env) { 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(), - ...(env.CODEX_HOME ? { CODEX_HOME: env.CODEX_HOME } : {}), + 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 } : {}), diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 46a687aa..3445a21a 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -38,7 +38,8 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { env: { PATH: "", HWLAB_CODE_AGENT_PROVIDER: "codex-cli", - HWLAB_CODE_AGENT_MODEL: "gpt-test" + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex" } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -942,7 +943,8 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret env: { PATH: "", HWLAB_CODE_AGENT_PROVIDER: "openai", - HWLAB_CODE_AGENT_MODEL: "gpt-test" + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex" } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -2056,7 +2058,8 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as env: { PATH: "", HWLAB_CODE_AGENT_PROVIDER: "codex-cli", - HWLAB_CODE_AGENT_MODEL: "gpt-test" + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex" } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -2088,10 +2091,10 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as assert.match(payload.error.userMessage, /Codex CLI binary/u); assert.match(payload.error.message, /Codex CLI command is not available/); assert.match(payload.error.nextEvidence, /HWLAB_CODE_AGENT_CODEX_COMMAND|PATH/u); - assert.deepEqual(payload.error.missingCommands, ["codex"]); + assert.deepEqual(payload.error.missingCommands, ["/tmp/hwlab-missing-codex"]); assert.ok(payload.error.missingEnv.includes("OPENAI_API_KEY")); assert.ok(payload.error.missingConfig.includes("OPENAI_API_KEY")); - assert.ok(payload.error.missingConfig.includes("command:codex")); + assert.ok(payload.error.missingConfig.includes("command:/tmp/hwlab-missing-codex")); assert.equal(payload.availability.status, "partial"); assert.match(payload.availability.blocker, /Codex CLI command/u); assert.equal(payload.availability.reason, "codex_cli_binary_missing"); diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index 011c3ff2..ce9bd13a 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -97,7 +97,8 @@ async function runLocalContractSmoke() { env: { PATH: "", HWLAB_CODE_AGENT_PROVIDER: "codex-cli", - HWLAB_CODE_AGENT_MODEL: "gpt-test" + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex" } } ); @@ -117,7 +118,7 @@ async function runLocalContractSmoke() { assert.equal(failed.error.blocker.code, "codex_cli_binary_missing"); assert.match(failed.error.nextEvidence, /HWLAB_CODE_AGENT_CODEX_COMMAND|PATH/u); assert.match(failed.error.message, /Codex CLI command is not available/); - assert.deepEqual(failed.error.missingCommands, ["codex"]); + assert.deepEqual(failed.error.missingCommands, ["/tmp/hwlab-missing-codex"]); assert.ok(failed.error.missingEnv.includes("OPENAI_API_KEY")); assert.equal(failed.availability.status, "partial"); assert.match(failed.availability.blocker, /Codex CLI command/u); diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 484e5285..dde01192 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -303,6 +303,7 @@ function runtimeScriptBase64() { import { createServer } from "node:http"; import http from "node:http"; import { spawn } from "node:child_process"; +import { chmodSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import path from "node:path"; @@ -323,6 +324,39 @@ const readOnlyRpcMethods = new Set([ "evidence.record.query" ]); +function ensureCodeAgentRuntimeBase() { + if (serviceId !== "hwlab-cloud-api") return; + const workspace = process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || process.env.HWLAB_CODE_AGENT_WORKSPACE || "/workspace/hwlab"; + const codexHome = process.env.CODEX_HOME || process.env.HWLAB_CODE_AGENT_CODEX_HOME || "/codex-home"; + process.env.HWLAB_CODE_AGENT_WORKSPACE = process.env.HWLAB_CODE_AGENT_WORKSPACE || workspace; + process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE = process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || workspace; + process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX = process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX || "workspace-write"; + process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED || "1"; + process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR || "repo-owned"; + process.env.CODEX_HOME = codexHome; + process.env.HWLAB_CODE_AGENT_CODEX_COMMAND = process.env.HWLAB_CODE_AGENT_CODEX_COMMAND || "/app/node_modules/.bin/codex"; + + mkdirSync(path.dirname(workspace), { recursive: true }); + if (!existsSync(workspace)) { + if (workspace === "/workspace/hwlab" && existsSync("/app")) { + symlinkSync("/app", workspace, "dir"); + } else { + mkdirSync(workspace, { recursive: true }); + } + } + mkdirSync(codexHome, { recursive: true }); + for (const target of [workspace, codexHome]) { + try { + const info = lstatSync(target); + chmodSync(info.isSymbolicLink() ? path.resolve(target) : target, 0o777); + } catch { + // Runtime readiness reports the concrete blocker without exposing secret values. + } + } +} + +ensureCodeAgentRuntimeBase(); + function sendJson(response, statusCode, body) { const payload = JSON.stringify(body, null, 2); response.writeHead(statusCode, { @@ -565,6 +599,14 @@ function dockerfile(baseImage, port) { "ARG HWLAB_COMMIT_ID", "ARG PORT", "ARG HWLAB_PORT", + "ENV CODEX_HOME=/codex-home", + "ENV HWLAB_CODE_AGENT_CODEX_HOME=/codex-home", + "ENV HWLAB_CODE_AGENT_WORKSPACE=/workspace/hwlab", + "ENV HWLAB_CODE_AGENT_CODEX_WORKSPACE=/workspace/hwlab", + "ENV HWLAB_CODE_AGENT_CODEX_SANDBOX=workspace-write", + "ENV HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED=1", + "ENV HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR=repo-owned", + "ENV HWLAB_CODE_AGENT_CODEX_COMMAND=/app/node_modules/.bin/codex", "ENV HWLAB_ENVIRONMENT=$HWLAB_ENVIRONMENT", "ENV HWLAB_SERVICE_ID=$HWLAB_SERVICE_ID", "ENV HWLAB_ARTIFACT_KIND=$HWLAB_ARTIFACT_KIND", @@ -581,6 +623,7 @@ function dockerfile(baseImage, port) { "COPY web ./web", "COPY tools ./tools", "COPY skills ./skills", + "RUN mkdir -p /workspace /codex-home && rm -rf /workspace/hwlab && ln -s /app /workspace/hwlab && chmod -R a+rwX /app /workspace /codex-home && test -x /app/node_modules/.bin/codex && /app/node_modules/.bin/codex --version >/tmp/hwlab-codex-version.txt", `RUN node -e "const fs=require('node:fs'); fs.writeFileSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', Buffer.from('${runtimeScriptBase64()}', 'base64')); fs.chmodSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', 0o755);"`, `EXPOSE ${port}`, "CMD [\"node\", \"/usr/local/bin/hwlab-dev-artifact-runtime.mjs\"]", diff --git a/scripts/m1-contract-smoke.mjs b/scripts/m1-contract-smoke.mjs index c773137a..c52c60f4 100644 --- a/scripts/m1-contract-smoke.mjs +++ b/scripts/m1-contract-smoke.mjs @@ -191,6 +191,7 @@ async function smokeCloudApi() { HWLAB_CLOUD_DB_SSL_MODE: "", HWLAB_CODE_AGENT_PROVIDER: "codex-cli", HWLAB_CODE_AGENT_MODEL: "gpt-m1-local", + HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex", OPENAI_API_KEY: "", PATH: "" }); @@ -282,7 +283,7 @@ async function smokeCloudApi() { assert.equal(agentChat.body.backend, "hwlab-cloud-api/codex-cli"); assert.equal(agentChat.body.error.code, "codex_cli_binary_missing"); assert.match(agentChat.body.error.message, /Codex CLI command is not available/); - assert.deepEqual(agentChat.body.error.missingCommands, ["codex"]); + assert.deepEqual(agentChat.body.error.missingCommands, ["/tmp/hwlab-missing-codex"]); assert.ok(agentChat.body.error.missingEnv.includes("OPENAI_API_KEY")); assert.equal(agentChat.body.availability.status, "partial"); assert.match(agentChat.body.availability.blocker, /Codex stdio|long-lived|Codex CLI command/u); diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index 0f970aea..1290acdd 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -390,8 +390,11 @@ function validateCloudApiCodeAgentSource(ctx, env) { ); expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED, "1", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED", "cloud API Codex stdio adapter enabled flag"); expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR, "repo-owned", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", "cloud API Codex stdio supervisor mode"); + expectEqual(ctx, env.HWLAB_CODE_AGENT_WORKSPACE, "/workspace/hwlab", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_WORKSPACE", "cloud API Code Agent workspace contract"); expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, "/workspace/hwlab", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE", "cloud API Codex stdio workspace mount contract"); 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"); expect( ctx, env.HWLAB_CODE_AGENT_OPENAI_BASE_URL !== contract.egress.forbiddenDirectBaseUrl, @@ -556,8 +559,11 @@ function validateCloudApiCodeAgentArtifacts(ctx, workloads) { ); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED")?.value, "1", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED", "cloud API workload Codex stdio adapter enabled flag"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR")?.value, "repo-owned", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", "cloud API workload Codex stdio supervisor mode"); + expectEqual(ctx, env.get("HWLAB_CODE_AGENT_WORKSPACE")?.value, "/workspace/hwlab", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_WORKSPACE", "cloud API workload Code Agent workspace contract"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_WORKSPACE")?.value, "/workspace/hwlab", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE", "cloud API workload Codex stdio workspace mount contract"); 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"); expect( ctx, env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value !== contract.egress.forbiddenDirectBaseUrl, diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index 26b839d2..da061ef1 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -214,8 +214,11 @@ assert.equal( ); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED, "1", "cloud-api Codex stdio adapter enabled flag"); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR, "repo-owned", "cloud-api Codex stdio supervisor mode"); +assert.equal(cloudApi.env.HWLAB_CODE_AGENT_WORKSPACE, "/workspace/hwlab", "cloud-api Code Agent workspace contract"); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, "/workspace/hwlab", "cloud-api Codex stdio workspace mount contract"); 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"); assert.notEqual( cloudApi.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL, DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.forbiddenDirectBaseUrl, @@ -276,8 +279,11 @@ function assertCodeAgentProviderWorkloadContract(env) { ); assert.equal(env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED?.value, "1", "cloud-api workload Codex stdio adapter enabled flag"); assert.equal(env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR?.value, "repo-owned", "cloud-api workload Codex stdio supervisor mode"); + assert.equal(env.HWLAB_CODE_AGENT_WORKSPACE?.value, "/workspace/hwlab", "cloud-api workload Code Agent workspace contract"); assert.equal(env.HWLAB_CODE_AGENT_CODEX_WORKSPACE?.value, "/workspace/hwlab", "cloud-api workload Codex stdio workspace mount contract"); 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"); assert.notEqual( env.HWLAB_CODE_AGENT_OPENAI_BASE_URL?.value, contract.egress.forbiddenDirectBaseUrl, diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 37aafd77..3771f5b5 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -2948,6 +2948,8 @@ function codeAgentBlockerChineseLabel(code) { openai_responses_fallback_not_session: "文本 fallback 不是长会话", one_shot_runner_not_long_lived: "一次性执行不是可复用 session", provider_token_boundary: "token 边界未配置", + codex_home_missing: "CODEX_HOME 不存在或不可读", + codex_home_write_blocked: "CODEX_HOME 不可写", workspace_mount_missing: "工作区挂载不可读", workspace_write_boundary_blocked: "workspace-write 沙箱不可写", codex_stdio_egress_boundary: "DEV egress 边界不合规"