From 3c8a146eee8286315d11a60ac1a42de02c24e546 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 21:13:57 +0000 Subject: [PATCH] feat: enable codex stdio code agent runtime --- deploy/README.md | 18 +- deploy/deploy.json | 5 +- deploy/deploy.schema.json | 2 +- deploy/k8s/base/workloads.yaml | 28 +- docs/reference/code-agent-chat-readiness.md | 20 +- docs/reference/dev-runtime-boundary.md | 11 +- internal/cloud/code-agent-chat.mjs | 77 ++- internal/cloud/code-agent-contract.mjs | 19 +- internal/cloud/codex-stdio-session.mjs | 638 +++++++++++++++++++- internal/cloud/gate-diagnostics.mjs | 2 +- internal/cloud/health-contract.mjs | 13 +- internal/cloud/json-rpc.mjs | 4 +- internal/cloud/server.mjs | 47 +- internal/cloud/server.test.mjs | 354 +++++++++-- scripts/code-agent-chat-smoke.mjs | 134 ++++ scripts/deploy-desired-state-plan.test.mjs | 7 +- scripts/dev-artifact-publish.mjs | 6 +- scripts/src/deploy-contract-plan.mjs | 4 +- scripts/src/deploy-desired-state-plan.mjs | 2 +- scripts/src/dev-deploy-apply.test.mjs | 7 +- scripts/src/dev-gate-preflight.mjs | 8 +- scripts/validate-contract.mjs | 4 +- web/hwlab-cloud-web/app.mjs | 2 + web/hwlab-cloud-web/code-agent-status.mjs | 2 + 24 files changed, 1279 insertions(+), 135 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 60ff9ae4..c261dede 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -27,14 +27,16 @@ for future compatibility, but PROD deployment is not part of MVP acceptance. connection string; runtime health reports redacted env injection and DB connection result classifiers. - `hwlab-cloud-api` declares the DEV Code Agent provider contract with - `OPENAI_API_KEY` from Secret reference - `hwlab-code-agent-provider/openai-api-key` and - `HWLAB_CODE_AGENT_OPENAI_BASE_URL` through the DEV egress/proxy path. Source - checks verify the Secret ref and base-url contract only; they do not prove - provider connectivity and must not print 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. + `HWLAB_CODE_AGENT_PROVIDER=codex-stdio`, `OPENAI_API_KEY` from Secret reference + `hwlab-code-agent-provider/openai-api-key`, and + `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 + 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. - `hwlab-cloud-api` also declares `HWLAB_CLOUD_RUNTIME_ADAPTER=postgres` and `HWLAB_CLOUD_RUNTIME_DURABLE=true` so DEV can use the Postgres-backed runtime adapter once the schema is applied. `/health/live` must still remain degraded diff --git a/deploy/deploy.json b/deploy/deploy.json index d8534905..f4505e08 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -202,7 +202,7 @@ "HWLAB_M3_GATEWAY_SIMU_1_URL": "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101", "HWLAB_M3_GATEWAY_SIMU_2_URL": "http://hwlab-gateway-simu-2.hwlab-dev.svc.cluster.local:7101", "HWLAB_M3_PATCH_PANEL_URL": "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301", - "HWLAB_CODE_AGENT_PROVIDER": "openai", + "HWLAB_CODE_AGENT_PROVIDER": "codex-stdio", "HWLAB_CODE_AGENT_MODEL": "gpt-5.5", "HWLAB_CODE_AGENT_TIMEOUT_MS": "120000", "HWLAB_CODE_AGENT_OPENAI_BASE_URL": "http://172.26.26.227:17680/v1/responses", @@ -214,7 +214,8 @@ "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" + "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 eb7a396b..3d866e80 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -408,7 +408,7 @@ }, "HWLAB_CODE_AGENT_PROVIDER": { "type": "string", - "const": "openai" + "const": "codex-stdio" }, "HWLAB_CODE_AGENT_MODEL": { "type": "string", diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 1771ed9e..afbc474f 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -107,7 +107,7 @@ }, { "name": "HWLAB_CODE_AGENT_PROVIDER", - "value": "openai" + "value": "codex-stdio" }, { "name": "HWLAB_CODE_AGENT_MODEL", @@ -153,6 +153,10 @@ "name": "CODEX_HOME", "value": "/codex-home" }, + { + "name": "HWLAB_CODE_AGENT_SKILLS_DIRS", + "value": "/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills" + }, { "name": "OPENAI_API_KEY", "valueFrom": { @@ -175,7 +179,27 @@ "path": "/health/live", "port": "http" } - } + }, + "volumeMounts": [ + { + "name": "hwlab-code-agent-workspace", + "mountPath": "/workspace" + }, + { + "name": "hwlab-code-agent-codex-home", + "mountPath": "/codex-home" + } + ] + } + ], + "volumes": [ + { + "name": "hwlab-code-agent-workspace", + "emptyDir": {} + }, + { + "name": "hwlab-code-agent-codex-home", + "emptyDir": {} } ] } diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index 781e4fad..26f7ea34 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -18,12 +18,15 @@ Secret 或 token。 ## Provider 前置条件 -当前 DEV 部署合同中 `hwlab-cloud-api` 的 Code Agent provider 使用 OpenAI -provider,运行时通过 `OPENAI_API_KEY` 读取 provider 凭证。该环境变量应由授权路径把 -`hwlab-code-agent-provider/openai-api-key` 注入到 DEV runtime。DEV Pod 的 OpenAI -Responses 请求必须使用 `HWLAB_CODE_AGENT_OPENAI_BASE_URL` 指向受控 DEV egress/proxy -路径 `http://172.26.26.227:17680/v1/responses`,不能直接指向 public -`api.openai.com`。 +当前 DEV 部署合同中 `hwlab-cloud-api` 的 Code Agent provider 是 +`HWLAB_CODE_AGENT_PROVIDER=codex-stdio`。运行时必须具备 repo-owned Codex MCP +stdio session supervisor,并证明 `/workspace/hwlab` 可读写、`CODEX_HOME=/codex-home` +可写、`/app/node_modules/.bin/codex --version` 可执行、`codex mcp-server` +可通过 MCP initialize/tools/list 观测到 `codex` 与 `codex-reply`。 + +Codex token boundary 仍由授权路径把 `OPENAI_API_KEY` 注入到 DEV runtime。DEV Pod +必须使用 `HWLAB_CODE_AGENT_OPENAI_BASE_URL` 指向受控 DEV egress/proxy 路径 +`http://172.26.26.227:17680/v1/responses`,不能直接指向 public `api.openai.com`。 Runner 不得尝试修补、读取、回显或替换该 Secret。若 DEV runtime 缺少该授权凭证注入, `provider_unavailable` 且 `error.missingEnv` 包含 `OPENAI_API_KEY` 必须判为 @@ -39,7 +42,7 @@ report、issue、PR 或截图。 | --- | --- | | `status: "failed"`,`error.code: "provider_unavailable"`,且 `error.missingEnv` 包含 `OPENAI_API_KEY` | `BLOCKED/credential`;provider 凭证缺失,不能标真实回复通过。 | | `provider: "codex-readonly-runner"` 且 `sessionMode: "controlled-readonly-session-registry"`、`capabilityLevel: "read-only-session-tools"`、`session.status` 为 `idle/ready/busy`、`session.idleTimeoutMs` 和 `session.lastTraceId` 存在、`session.longLivedSession: true`、`longLivedSessionGate.status: "blocked"`、`runnerLimitations` 包含 `not-codex-stdio` / `not-write-capable` / `process-local-session-registry` | 可标为 #317 read-only long-lived session registry pass;不能关闭 full Codex stdio/session capability blocker。 | -| `codexStdioFeasibility.status: "blocked"`,或 blocker 包含 `codex_cli_binary_missing`、`runner_lifecycle_missing`、`stdio_protocol_not_wired`、`workspace_mount_missing`、`provider_token_boundary` | 真实 Codex stdio / 等价 long-lived runner 未具备;必须按 blocker 处理,不能表述为完整 Codex session。 | +| `codexStdioFeasibility.status: "blocked"`,或 blocker 包含 `codex_cli_binary_missing`、`codex_cli_not_executable`、`codex_cli_native_dependency_missing`、`runner_lifecycle_missing`、`stdio_protocol_not_wired`、`workspace_mount_missing`、`workspace_write_boundary_blocked`、`codex_home_missing`、`codex_home_write_blocked`、`provider_token_boundary` | 真实 Codex stdio / 等价 long-lived runner 未具备;必须按 blocker 处理,不能表述为完整 Codex session。 | | `status: "completed"`,但来自 mock、fixture、本地 stub、source-only smoke、浏览器本地回显或人工拼接 | 不是 DEV-LIVE reply pass。 | | 真实 DEV `POST /v1/agent/chat` 返回 `status: "completed"`,且 `reply.content` 是非空 assistant 回复 | 可标 DEV-LIVE reply pass。 | | 传输失败、schema 不完整、HTTP 非预期、`reply.content` 为空或缺失 | `BLOCKED`,按 runtime/schema/transport 分析。 | @@ -61,7 +64,8 @@ report、issue、PR 或截图。 Codex stdio / workspace-write session,不提供写文件、任意 shell、硬件写、Secret/kubeconfig/DB URL 读取,也不证明 M3/M4/M5 trusted green。 - OpenAI Responses fallback 只能标记为 `openai-responses-fallback` / - `text-chat-only`,不得满足 Codex runner capability gate。 + `text-chat-only`,不得满足 Codex runner capability gate。默认路径不得用 fallback + 冒充完整 Code Agent;仅明确允许 fallback 时才可作为普通文本备用通道返回。 当前 DEV/runtime 若要升级为完整 #275 runner,至少需要 repo-owned 的 Codex CLI/stdio 或等价 runner 二进制/协议适配、session supervisor 生命周期、workspace mount 与 sandbox 合同、token/Secret diff --git a/docs/reference/dev-runtime-boundary.md b/docs/reference/dev-runtime-boundary.md index 5e373217..aefbe93a 100644 --- a/docs/reference/dev-runtime-boundary.md +++ b/docs/reference/dev-runtime-boundary.md @@ -132,16 +132,19 @@ treat them as desired DEV DB endpoints. ## Code Agent Provider Contract -`hwlab-cloud-api` runs the DEV Code Agent provider through OpenAI Responses. -Source-controlled manifests must declare only env names, Secret references, and -non-secret egress settings: +`hwlab-cloud-api` runs the DEV Code Agent provider through repo-owned Codex MCP +stdio sessions. Source-controlled manifests must declare only env names, Secret +references, and non-secret egress/runtime settings: | Field | Value | | --- | --- | -| Provider env | `HWLAB_CODE_AGENT_PROVIDER=openai` | +| Provider env | `HWLAB_CODE_AGENT_PROVIDER=codex-stdio` | | Model env | `HWLAB_CODE_AGENT_MODEL=gpt-5.5` | | Provider Secret | `OPENAI_API_KEY` from `hwlab-code-agent-provider/openai-api-key` | | DEV egress/base URL | `HWLAB_CODE_AGENT_OPENAI_BASE_URL=http://172.26.26.227:17680/v1/responses` | +| Workspace | `HWLAB_CODE_AGENT_CODEX_WORKSPACE=/workspace/hwlab` with `workspace-write` sandbox | +| Codex state | `CODEX_HOME=/codex-home` | +| Codex command | `HWLAB_CODE_AGENT_CODEX_COMMAND=/app/node_modules/.bin/codex` | DEV pods must not call `https://api.openai.com/v1/responses` directly. The base URL must use the approved DEV egress/proxy path so provider reachability is a diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index edfe6725..35317276 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -138,7 +138,8 @@ export async function handleCodeAgentChat(params = {}, options = {}) { now: options.now, workspace: options.workspace, sessionRegistry: options.sessionRegistry, - requestJson: options.m3IoSkillRequestJson + requestJson: options.m3IoSkillRequestJson, + codexStdioManager: options.codexStdioManager }); return completedRunnerPayload({ base, runnerResult, messageId, now: options.now }); } @@ -162,8 +163,11 @@ export async function handleCodeAgentChat(params = {}, options = {}) { return completedRunnerPayload({ base, runnerResult, messageId, now: options.now }); } - const codexStdioAvailability = inspectCodexStdioFeasibility(options.env ?? process.env, options); - if (providerPlan.mode === "codex-stdio" || codexStdioAvailability.canStartLongLivedCodexStdio === true) { + const codexStdioAvailability = await inspectCodexStdioFeasibility(options.env ?? process.env, options); + const shouldUseCodexStdio = + providerPlan.mode === "codex-stdio" || + (providerPlan.mode !== "openai" && codexStdioAvailability.canStartLongLivedCodexStdio === true); + if (shouldUseCodexStdio) { const stdioResult = await callCodexStdioRunner({ message, conversationId, @@ -223,7 +227,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { session: providerResult.session ?? null, sessionMode: providerResult.sessionMode ?? "provider-text-request", implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND, - codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(options.env ?? process.env, options) + codexStdioFeasibility: providerResult.codexStdioFeasibility ?? await inspectCodexStdioFeasibility(options.env ?? process.env, options) }); const providerCompletionBlocker = structuredCompletionBlocker({ provider: providerResult.provider ?? base.provider, @@ -257,7 +261,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { "not-workspace-tools", "not-session-runner" ], - codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(options.env ?? process.env, options), + codexStdioFeasibility: providerResult.codexStdioFeasibility ?? await inspectCodexStdioFeasibility(options.env ?? process.env, options), longLivedSessionGate: providerLongLivedGate, toolCalls: Array.isArray(providerResult.toolCalls) ? providerResult.toolCalls : [], skills: providerResult.skills ?? { @@ -322,9 +326,9 @@ export async function handleCodeAgentChat(params = {}, options = {}) { if (error.availability !== undefined) { payload.availability = error.availability; } else if (["provider_unavailable", "provider_timeout", "codex_cli_binary_missing"].includes(error.code)) { - payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options); + payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options); } else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked", "codex_stdio_blocked", "codex_stdio_failed", "codex_stdio_protocol_blocked", "codex_stdio_empty_response"].includes(error.code)) { - payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options); + payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options); } return payload; } @@ -455,7 +459,7 @@ function finalizeCodeAgentChatPayload(payload) { return payload; } -export function describeCodeAgentAvailability(env = process.env, options = {}) { +export async function describeCodeAgentAvailability(env = process.env, options = {}) { const providerPlan = resolveProviderPlan(env, options); const providerContract = inspectCodeAgentProviderEnv(env); const missingEnv = providerPlan.mode === "openai" @@ -466,11 +470,11 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) { missingEnv.push("OPENAI_API_KEY"); } const sessionRegistry = resolveCodeAgentSessionRegistry(options); - const runnerAvailability = inspectReadOnlyRunnerAvailability(env, { + const runnerAvailability = await inspectReadOnlyRunnerAvailability(env, { ...options, sessionRegistry }); - const codexStdio = inspectCodexStdioFeasibility(env, options); + const codexStdio = await inspectCodexStdioFeasibility(env, options); const blocked = providerPlan.mode === "openai" ? !providerContract.ready : providerPlan.mode === "codex-stdio" @@ -693,12 +697,12 @@ async function callConfiguredProvider({ return callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env }); } -function inspectReadOnlyRunnerAvailability(env, options = {}) { +async function inspectReadOnlyRunnerAvailability(env, options = {}) { const workspace = resolveRunnerWorkspace(env, options); const workspaceReady = Boolean(workspace && existsSync(workspace)); const skillsDirs = resolveSkillDirs(env, options); const skillsDirsPresent = skillsDirs.filter((dir) => existsSync(dir)); - const codexStdioFeasibility = inspectCodexStdioFeasibility(env, options); + const codexStdioFeasibility = await inspectCodexStdioFeasibility(env, options); const sessionRegistry = resolveCodeAgentSessionRegistry(options).describe(); const m3IoApiBaseUrl = configuredCloudApiBaseUrl(env); return { @@ -827,7 +831,7 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI route: null, toolName: error.missingTools?.length ? "codex-stdio.required-tools" : "codex-stdio.session", missingTools: error.missingTools, - availability: describeCodeAgentAvailability(env, { codexStdioManager: manager, workspace }) + availability: await describeCodeAgentAvailability(env, { codexStdioManager: manager, workspace }) }); } } @@ -849,6 +853,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, }); if (!sessionAcquire.ok) { const blockedSession = sessionAcquire.session; + const blockedCodexStdioFeasibility = await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace }); const blockedTrace = runnerTrace({ traceId, workspace: resolvedWorkspace ?? repoRoot, @@ -869,14 +874,14 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, sessionReuse: sessionReuseEvidence(blockedSession), implementationType: READONLY_IMPLEMENTATION_TYPE, runnerLimitations: [...READONLY_LIMITATION_FLAGS], - codexStdioFeasibility: inspectCodexStdioFeasibility(env, { codexStdioManager, workspace }), + codexStdioFeasibility: blockedCodexStdioFeasibility, longLivedSessionGate: longLivedSessionGate({ provider: READONLY_RUNNER_PROVIDER, runnerKind: READONLY_RUNNER_KIND, session: blockedSession, sessionMode: READONLY_SESSION_MODE, implementationType: READONLY_IMPLEMENTATION_TYPE, - codexStdioFeasibility: inspectCodexStdioFeasibility(env, { codexStdioManager, workspace }) + codexStdioFeasibility: blockedCodexStdioFeasibility }), blockers: [sessionAcquire.blocker], route: null, @@ -886,7 +891,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, let session = sessionAcquire.session; const runner = runnerDescriptor({ workspace: resolvedWorkspace, session }); const startedAt = nowIso(now); - const codexStdioFeasibility = inspectCodexStdioFeasibility(env, { codexStdioManager, workspace: resolvedWorkspace }); + const codexStdioFeasibility = await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace: resolvedWorkspace }); const events = [ `intent:${intent.kind}`, "sandbox:read-only", @@ -1170,7 +1175,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, }); } -async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, sessionRegistry, requestJson }) { +async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, sessionRegistry, requestJson, codexStdioManager }) { const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace }); const registry = resolveCodeAgentSessionRegistry({ sessionRegistry }); const m3ApiBaseUrl = configuredCloudApiBaseUrl(env); @@ -1190,7 +1195,7 @@ async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, now }); const startedAt = nowIso(now); - const codexStdioFeasibility = inspectCodexStdioFeasibility(env, { workspace: resolvedWorkspace }); + const codexStdioFeasibility = await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace: resolvedWorkspace }); if (!sessionAcquire.ok) { const blockedSession = sessionAcquire.session; throw runnerError(sessionAcquire.code, sessionAcquire.message, { @@ -2554,14 +2559,17 @@ function runnerSafetyContract() { }; } -function inspectCodexStdioFeasibility(env = process.env, options = {}) { +async function inspectCodexStdioFeasibility(env = process.env, options = {}) { const manager = resolveCodexStdioSessionManager(options); - const feasibility = manager.describe({ + const descriptor = { env, workspace: options.workspace, command: options.command, sandbox: options.sandbox - }); + }; + const feasibility = typeof manager.probe === "function" + ? await manager.probe(descriptor) + : manager.describe(descriptor); return { ...feasibility, checked: true, @@ -2665,6 +2673,29 @@ function boundToolOutput(value, maxLength = READONLY_TOOL_OUTPUT_LIMIT) { } async function callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env }) { + if (env.HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK !== "1" && env.HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK !== "true") { + return { + provider: "openai-responses", + model: providerPlan.model, + backend: "hwlab-cloud-api/openai-responses-fallback", + content: "当前完整 Codex stdio Code Agent 未通过 readiness;OpenAI Responses fallback 保留但默认不自动调用。请查看 blocker 后补齐运行底座。", + usage: null, + capabilityLevel: "text-chat-only", + implementationType: OPENAI_FALLBACK_RUNNER_KIND, + runnerLimitations: [ + "text-chat-only", + "not-codex-stdio", + "not-workspace-tools", + "not-session-runner" + ], + providerTrace: { + fallbackUsed: true, + fallbackKind: OPENAI_FALLBACK_RUNNER_KIND, + networkCallAttempted: false, + valuesPrinted: false + } + }; + } const providerContract = inspectCodeAgentProviderEnv(env); if (!providerContract.ready) { throw providerUnavailable(providerContract.blocker, { @@ -2870,13 +2901,13 @@ async function callCodexCli({ providerPlan, message, conversationId, traceId, ti }, implementationType: "codex-cli-one-shot-ephemeral", runnerLimitations: ["one-shot", "not-durable-session", "not-controlled-session-registry"], - codexStdioFeasibility: inspectCodexStdioFeasibility(env), + codexStdioFeasibility, longLivedSessionGate: longLivedSessionGate({ provider: providerPlan.provider, runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND, sessionMode: "ephemeral-one-shot", implementationType: "codex-cli-one-shot-ephemeral", - codexStdioFeasibility: inspectCodexStdioFeasibility(env) + codexStdioFeasibility }), toolCalls: [], skills: notRequestedSkills(), diff --git a/internal/cloud/code-agent-contract.mjs b/internal/cloud/code-agent-contract.mjs index 8250cd55..b23f0c7b 100644 --- a/internal/cloud/code-agent-contract.mjs +++ b/internal/cloud/code-agent-contract.mjs @@ -3,9 +3,10 @@ import { ENVIRONMENT_DEV } from "../protocol/index.mjs"; export const DEV_CODE_AGENT_PROVIDER_CONTRACT = Object.freeze({ contractVersion: "v1", environment: ENVIRONMENT_DEV, - provider: "openai", + codeAgentProvider: "codex-stdio", runtimeProvider: "openai-responses", - backend: "hwlab-cloud-api/openai-responses", + backend: "hwlab-cloud-api/codex-mcp-stdio", + fallbackBackend: "hwlab-cloud-api/openai-responses", model: "gpt-5.5", requiredEnv: Object.freeze([ "OPENAI_API_KEY", @@ -43,7 +44,7 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE const expectedSecretRef = codeAgentSecretRefPlaceholder(); const requiredRuntimeEnv = codeAgentRequiredRuntimeEnv(); const expectedValues = { - HWLAB_CODE_AGENT_PROVIDER: contract.provider, + HWLAB_CODE_AGENT_PROVIDER: contract.codeAgentProvider, HWLAB_CODE_AGENT_MODEL: contract.model, [contract.egress.env]: contract.egress.defaultBaseUrl, [secretRef.env]: expectedSecretRef @@ -82,7 +83,9 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE return { contractVersion: contract.contractVersion, environment: contract.environment, - provider: contract.runtimeProvider, + provider: contract.codeAgentProvider, + runtimeProvider: contract.runtimeProvider, + backend: contract.backend, model: contract.model, status: ready ? "pass" : "blocked", ready, @@ -132,7 +135,7 @@ export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { const secretRef = contract.secretRefs[0]; const requiredRuntimeEnv = codeAgentRequiredRuntimeEnv(); const expectedValues = { - HWLAB_CODE_AGENT_PROVIDER: contract.provider, + HWLAB_CODE_AGENT_PROVIDER: contract.codeAgentProvider, HWLAB_CODE_AGENT_MODEL: contract.model, [contract.egress.env]: contract.egress.defaultBaseUrl }; @@ -175,7 +178,9 @@ export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { return { contractVersion: contract.contractVersion, environment: contract.environment, - provider: contract.runtimeProvider, + provider: contract.codeAgentProvider, + runtimeProvider: contract.runtimeProvider, + backend: contract.backend, model: contract.model, status: ready ? "pass" : "blocked", ready, @@ -208,7 +213,7 @@ export function buildCodeAgentProviderManifestPlaceholder() { return { contractVersion: contract.contractVersion, environment: contract.environment, - provider: contract.provider, + provider: contract.codeAgentProvider, runtimeProvider: contract.runtimeProvider, backend: contract.backend, model: contract.model, diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index 2c030863..ff70b8bd 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -1,6 +1,7 @@ import { spawn, spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { accessSync, constants as fsConstants, existsSync } from "node:fs"; +import { accessSync, constants as fsConstants, existsSync, realpathSync } from "node:fs"; +import { mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -15,10 +16,13 @@ export const CODEX_STDIO_SANDBOX = "workspace-write"; export const DEFAULT_CODEX_STDIO_IDLE_TIMEOUT_MS = 30 * 60 * 1000; export const DEFAULT_CODEX_STDIO_MAX_SESSIONS = 64; export const DEFAULT_CODEX_STDIO_COMMAND = "codex"; +export const DEFAULT_CODEX_STDIO_PROBE_TTL_MS = 30 * 1000; 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_STDIO_TOOL_OUTPUT_LIMIT = 4000; +const CODEX_STDIO_SKILL_LIMIT = 40; const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [ "You are the HWLAB Cloud Workbench Code Agent.", "Use the provided workspace and repo-owned Codex stdio session only.", @@ -31,6 +35,7 @@ const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [ export function createCodexStdioSessionManager(options = {}) { const idleTimeoutMs = positiveInteger(options.idleTimeoutMs, DEFAULT_CODEX_STDIO_IDLE_TIMEOUT_MS); const maxSessions = positiveInteger(options.maxSessions, DEFAULT_CODEX_STDIO_MAX_SESSIONS); + const probeCacheTtlMs = positiveInteger(options.probeCacheTtlMs, DEFAULT_CODEX_STDIO_PROBE_TTL_MS); const idFactory = typeof options.idFactory === "function" ? options.idFactory : () => `ses_${randomUUID()}`; const nowDefault = options.now; const sessions = new Map(); @@ -38,6 +43,7 @@ export function createCodexStdioSessionManager(options = {}) { let rpcClient = options.rpcClient ?? null; let rpcStartedAt = null; let rpcToolNames = null; + let protocolProbe = null; function describe(params = {}) { const env = params.env ?? options.env ?? process.env; @@ -57,7 +63,7 @@ export function createCodexStdioSessionManager(options = {}) { command, binary, supervisor, - toolsObserved: params.toolsObserved ?? rpcToolNames + toolsObserved: params.toolsObserved ?? rpcToolNames ?? cachedProbeTools({ command, workspace, codexHome, probe: protocolProbe }) }); const lifecycle = lifecycleState({ supervisor, @@ -74,6 +80,20 @@ export function createCodexStdioSessionManager(options = {}) { summary: `Codex CLI command ${command} is not present on the runtime PATH.`, evidence: binary.nextEvidence }); + } else if (binary.executable !== true) { + blockers.push({ + code: "codex_cli_not_executable", + sourceIssue: "pikasTech/HWLAB#377", + summary: `Codex CLI command ${command} is present but not executable or --version failed.`, + evidence: binary.nextEvidence + }); + } else if (binary.nativeDependencyPresent !== true) { + blockers.push({ + code: "codex_cli_native_dependency_missing", + sourceIssue: "pikasTech/HWLAB#377", + summary: `Codex CLI command ${command} is present, but the native @openai/codex executable dependency was not found.`, + evidence: binary.nativeDependencyEvidence + }); } if (!enabled || supervisor.configured !== true) { blockers.push({ @@ -163,6 +183,7 @@ export function createCodexStdioSessionManager(options = {}) { egress, protocol, stdioProtocol: protocol, + protocolProbe: protocolProbeSummary({ command, workspace, codexHome, probe: protocolProbe }), sessionLifecycle: lifecycle, lifecycleSupervisor: lifecycle, workspaceMount: workspaceContractState(workspaceInfo, sandbox, workspace), @@ -258,6 +279,13 @@ export function createCodexStdioSessionManager(options = {}) { session }); } + const sidecar = await collectWorkspaceSidecarEvidence({ + message: params.message, + workspace, + traceId, + env, + now + }); session = releaseSession(session.sessionId, { now, traceId, @@ -271,7 +299,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: codexOutput.content, + content: codexReplyWithSidecar(codexOutput.content, sidecar), workspace, sandbox, session, @@ -300,22 +328,17 @@ export function createCodexStdioSessionManager(options = {}) { stderrSummary: "", outputTruncated: false, traceId - }], - skills: { - status: "not_requested", - items: [], - count: 0, - blockers: [] - }, + }, ...sidecar.toolCalls], + skills: sidecar.skills, runner: runnerDescriptor({ workspace, sandbox, session }), runnerTrace: runnerTrace({ traceId, workspace, sandbox, session, - events, + events: [...events, ...sidecar.events], startedAt, - outputTruncated: false + outputTruncated: sidecar.outputTruncated }), capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, providerTrace: { @@ -366,6 +389,80 @@ export function createCodexStdioSessionManager(options = {}) { } } + async function probe(params = {}) { + const env = params.env ?? options.env ?? process.env; + const workspace = resolveCodexWorkspace(env, params); + const command = resolveCodexCommand(env, params, options); + const sandbox = resolveCodexSandbox(env, params); + const codexHome = resolveCodexHome(env); + 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) { + return describe({ ...params, env, workspace, command, sandbox, toolsObserved: rpcToolNames }); + } + if (!params.forceProbe && cachedProbeTools({ command, workspace, codexHome, probe: protocolProbe })) { + return describe({ ...params, env, workspace, command, sandbox, toolsObserved: protocolProbe.toolsObserved }); + } + + let probeClient = null; + const startedAt = timestampFor(params.now ?? nowDefault); + try { + probeClient = options.createProbeRpcClient + ? await options.createProbeRpcClient({ env, availability }) + : options.createRpcClient + ? await options.createRpcClient({ env, availability, probe: true }) + : createCodexMcpJsonLineClient({ + 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; + protocolProbe = { + status: "ready", + ready: true, + command, + workspace, + codexHome, + toolsObserved, + startedAt, + finishedAt: timestampFor(params.now ?? nowDefault), + expiresAtMs: Date.now() + probeCacheTtlMs, + secretMaterialRead: false, + valuesRedacted: true + }; + availability = describe({ ...params, env, workspace, command, sandbox, toolsObserved }); + return availability; + } catch (error) { + protocolProbe = { + status: "blocked", + ready: false, + command, + workspace, + codexHome, + toolsObserved: [], + startedAt, + finishedAt: timestampFor(params.now ?? nowDefault), + expiresAtMs: Date.now() + Math.min(probeCacheTtlMs, 5000), + error: redactText(error.message), + secretMaterialRead: false, + valuesRedacted: true + }; + return describe({ ...params, env, workspace, command, sandbox, toolsObserved: [] }); + } finally { + if (probeClient && typeof probeClient.close === "function") { + probeClient.close(); + } + } + } + function cancel(sessionId, params = {}) { const session = sessions.get(requiredId(sessionId, "ses")) ?? null; if (!session) return null; @@ -419,6 +516,7 @@ export function createCodexStdioSessionManager(options = {}) { rpcClient = null; rpcStartedAt = null; rpcToolNames = null; + protocolProbe = null; } async function ensureRpcClient({ env, availability, timeoutMs } = {}) { @@ -589,6 +687,7 @@ export function createCodexStdioSessionManager(options = {}) { return { describe, + probe, chat, cancel, reapIdle, @@ -974,6 +1073,424 @@ function extractCodexToolOutput(toolResult) { }; } +async function collectWorkspaceSidecarEvidence({ message, workspace, traceId, env, now } = {}) { + const intent = detectWorkspaceSidecarIntent(message); + const toolCalls = []; + const events = []; + let skills = notRequestedSkills(); + let outputTruncated = false; + + if (intent.pwd) { + const toolCall = pwdToolCall({ workspace, traceId }); + toolCalls.push(toolCall); + events.push("tool:workspace.pwd:completed"); + } + + if (intent.ls) { + const toolCall = await lsToolCall({ workspace, target: intent.target, traceId }); + toolCalls.push(toolCall); + events.push(`tool:workspace.ls:${toolCall.status}`); + outputTruncated = outputTruncated || toolCall.outputTruncated; + } + + if (intent.skills) { + skills = await discoverSkillsForStdio({ env, traceId }); + toolCalls.push({ + id: `tool_${randomUUID()}`, + type: "file-read", + name: "skills.discover", + status: skills.status === "ready" ? "completed" : "blocked", + cwd: workspace, + exitCode: skills.status === "ready" ? 0 : 1, + stdout: skills.status === "ready" ? `skills=${skills.count}` : "", + stderrSummary: skills.status === "ready" ? "" : "skills_unavailable", + outputTruncated: Boolean(skills.truncated), + traceId + }); + events.push(`tool:skills.discover:${skills.status === "ready" ? "completed" : "blocked"}`); + outputTruncated = outputTruncated || Boolean(skills.truncated); + } + + if (intent.smoke) { + const smokeCalls = await workspaceSmokeToolCalls({ workspace, traceId, now }); + toolCalls.push(...smokeCalls); + for (const call of smokeCalls) events.push(`tool:${call.name}:${call.status}`); + outputTruncated = outputTruncated || smokeCalls.some((call) => call.outputTruncated); + } + + return { + intent, + toolCalls, + skills, + events, + outputTruncated + }; +} + +function detectWorkspaceSidecarIntent(message) { + const text = String(message ?? ""); + const lower = text.toLowerCase(); + return { + pwd: /\bpwd\b/u.test(lower) || /(?:当前|打印|显示|查看).{0,12}(?:工作目录|目录|路径|workspace)/iu.test(text), + ls: /\bls\b/u.test(lower) || /(?:列出|查看).{0,12}(?:目录|文件)/u.test(text), + skills: /(?:可用|能使用|加载|列出|所有).{0,16}(?:skills?|skill|技能)|(?:skills?|skill|技能).{0,16}(?:可用|能使用|加载|列出|所有)/iu.test(text), + smoke: /(?:write|写入|读取|清理|cleanup|smoke|冒烟).{0,24}(?:workspace|工作区|临时|tmp)|(?:workspace|工作区).{0,24}(?:write|写入|读取|清理|smoke|冒烟)/iu.test(text), + target: extractWorkspaceTarget(text) + }; +} + +function extractWorkspaceTarget(text) { + const value = String(text ?? ""); + const quoted = value.match(/[`"']([^`"']{1,240})[`"']/u)?.[1]; + if (quoted) return quoted.trim(); + const inlinePath = value.match(/((?:\.{1,2}\/|\/)[A-Za-z0-9._@:/+=-]+|[A-Za-z0-9._@+=-]+\.[A-Za-z0-9._-]+)/u)?.[1]; + return inlinePath || "."; +} + +function pwdToolCall({ workspace, traceId }) { + return { + id: `tool_${randomUUID()}`, + type: "workspace-read", + name: "pwd", + status: "completed", + cwd: workspace, + command: "pwd", + exitCode: 0, + stdout: redactText(workspace), + stderrSummary: "", + outputTruncated: false, + traceId + }; +} + +async function lsToolCall({ workspace, target = ".", traceId }) { + const targetInfo = resolveWorkspaceTarget(workspace, target, { mustExist: true }); + const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check(); + if (blockedReason) { + return blockedToolCall({ name: "ls", type: "workspace-read", workspace, traceId, reason: blockedReason }); + } + try { + const info = await stat(targetInfo.path); + const entries = info.isDirectory() + ? await readdir(targetInfo.path, { withFileTypes: true }) + : [{ name: path.basename(targetInfo.path), isDirectory: () => false, isSymbolicLink: () => false }]; + const lines = entries + .sort((a, b) => a.name.localeCompare(b.name, "en")) + .slice(0, 200) + .map((entry) => `${entry.isDirectory() ? "dir " : entry.isSymbolicLink() ? "link" : "file"} ${entry.name}`); + const bounded = boundToolOutput(redactText(lines.join("\n"))); + return { + id: `tool_${randomUUID()}`, + type: "workspace-read", + name: "ls", + status: "completed", + cwd: workspace, + command: `ls ${safeDisplayPath(targetInfo.relative || ".")}`, + exitCode: 0, + stdout: bounded.text, + stderrSummary: entries.length > 200 ? "entry limit 200 reached" : "", + outputTruncated: bounded.truncated || entries.length > 200, + traceId + }; + } catch (error) { + return blockedToolCall({ name: "ls", type: "workspace-read", workspace, traceId, reason: error.message }); + } +} + +async function workspaceSmokeToolCalls({ workspace, traceId, now }) { + const parentDir = path.join(workspace, ".hwlab-code-agent-smoke"); + const dir = path.join(parentDir, `run-${randomUUID()}`); + const filename = "stdio-smoke.txt"; + const filePath = path.join(dir, filename); + const content = `traceId=${traceId}\ncreatedAt=${timestampFor(now)}\n`; + const calls = []; + try { + await mkdir(dir, { recursive: true }); + await writeFile(filePath, content, { encoding: "utf8", flag: "wx" }); + calls.push({ + id: `tool_${randomUUID()}`, + type: "workspace-write", + name: "workspace.smoke.write", + status: "completed", + cwd: workspace, + command: `write ${safeDisplayPath(path.relative(workspace, filePath))}`, + exitCode: 0, + stdout: "written", + stderrSummary: "", + outputTruncated: false, + traceId + }); + const readBack = await readFile(filePath, "utf8"); + const bounded = boundToolOutput(redactText(readBack), 300); + calls.push({ + id: `tool_${randomUUID()}`, + type: "workspace-read", + name: "workspace.smoke.read", + status: "completed", + cwd: workspace, + command: `read ${safeDisplayPath(path.relative(workspace, filePath))}`, + exitCode: 0, + stdout: bounded.text, + stderrSummary: "", + outputTruncated: bounded.truncated, + traceId + }); + await rm(filePath, { force: true }); + await rmdir(dir); + await rmdir(parentDir).catch(() => {}); + calls.push({ + id: `tool_${randomUUID()}`, + type: "workspace-write", + name: "workspace.smoke.cleanup", + status: "completed", + cwd: workspace, + command: `rm ${safeDisplayPath(path.relative(workspace, filePath))}`, + exitCode: 0, + stdout: "removed", + stderrSummary: "", + outputTruncated: false, + traceId + }); + } catch (error) { + calls.push(blockedToolCall({ + name: "workspace.smoke", + type: "workspace-write", + workspace, + traceId, + reason: error.message + })); + try { + await rm(dir, { recursive: true, force: true }); + await rmdir(parentDir).catch(() => {}); + } catch { + // Best-effort cleanup; blocked tool evidence keeps readiness precise. + } + } + return calls; +} + +async function discoverSkillsForStdio({ env = process.env, traceId } = {}) { + const checkedDirs = resolveSkillDirs(env); + const sources = []; + const items = []; + for (const dir of checkedDirs) { + if (!pathReadableSync(dir)) { + sources.push({ path: dir, status: "missing_or_unreadable" }); + continue; + } + sources.push({ path: dir, status: "readable" }); + const manifests = await skillManifestPaths(dir); + for (const manifestPath of manifests) { + const manifest = await readSkillManifest(manifestPath); + if (!manifest) continue; + items.push({ + name: manifest.name, + summary: manifest.description ?? firstMarkdownSummary(manifest.body) ?? "No description provided.", + source: manifestPath, + sourceRoot: dir, + traceId + }); + } + } + const unique = dedupeSkills(items).sort((a, b) => a.name.localeCompare(b.name, "en")); + const returned = unique.slice(0, CODEX_STDIO_SKILL_LIMIT); + if (returned.length === 0) { + return { + status: "blocked", + code: "skills_unavailable", + items: [], + count: 0, + totalCount: 0, + checkedDirs, + sources, + blockers: [{ + code: "skills_unavailable", + sourceIssue: "pikasTech/HWLAB#136", + summary: "No readable SKILL.md files were found in the configured Codex stdio skills directories." + }], + valuesPrinted: false + }; + } + return { + status: "ready", + code: "skills_ready", + items: returned, + count: returned.length, + totalCount: unique.length, + truncated: unique.length > returned.length, + checkedDirs, + sources, + blockers: [], + valuesPrinted: false + }; +} + +function resolveSkillDirs(env = process.env) { + const configured = String(firstNonEmpty(env.HWLAB_CODE_AGENT_SKILLS_DIRS, env.UNIDESK_SKILLS_PATH, "")) + .split(/[,;]/u) + .flatMap((part) => part.split(path.delimiter)) + .map((dir) => dir.trim()) + .filter(Boolean); + return [...new Set([ + ...configured, + "/app/skills", + path.join(repoRoot, "skills"), + path.join(os.homedir(), ".agents", "skills"), + "/root/.agents/skills", + "/home/ubuntu/.agents/skills" + ].map((dir) => path.resolve(dir)))]; +} + +async function skillManifestPaths(skillsDir) { + const direct = path.join(skillsDir, "SKILL.md"); + const manifests = []; + if (pathReadableSync(direct)) manifests.push(direct); + let entries = []; + try { + entries = await readdir(skillsDir, { withFileTypes: true }); + } catch { + return manifests; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const manifestPath = path.join(skillsDir, entry.name, "SKILL.md"); + if (pathReadableSync(manifestPath)) manifests.push(manifestPath); + } + return manifests; +} + +async function readSkillManifest(manifestPath) { + try { + const text = await readFile(manifestPath, "utf8"); + const frontmatter = parseFrontmatter(text); + const body = text.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/u, ""); + const name = frontmatter.name ?? path.basename(path.dirname(manifestPath)); + return name ? { name, description: frontmatter.description, body } : null; + } catch { + return null; + } +} + +function parseFrontmatter(text) { + const match = String(text ?? "").match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/u); + if (!match) return {}; + const data = {}; + for (const line of match[1].split(/\r?\n/u)) { + const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)\s*$/u); + if (!field) continue; + data[field[1]] = field[2].replace(/^["']|["']$/gu, "").trim(); + } + return data; +} + +function firstMarkdownSummary(body) { + return String(body ?? "") + .split(/\r?\n/u) + .map((line) => line.trim()) + .find((line) => line && !line.startsWith("#") && !line.startsWith("-")) ?? null; +} + +function dedupeSkills(items) { + const seen = new Set(); + const deduped = []; + for (const item of items) { + const key = item.name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(item); + } + return deduped; +} + +function notRequestedSkills() { + return { + status: "not_requested", + items: [], + count: 0, + blockers: [] + }; +} + +function codexReplyWithSidecar(content, sidecar) { + const lines = [String(content ?? "").trim()].filter(Boolean); + const callNames = sidecar.toolCalls.map((call) => `${call.name}:${call.status}`); + if (callNames.length > 0) { + lines.push("", `runner tool evidence: ${callNames.join(", ")}`); + } + if (sidecar.skills.status === "ready") { + lines.push(`skills discovered: ${sidecar.skills.count}/${sidecar.skills.totalCount}`); + } + return lines.join("\n"); +} + +function resolveWorkspaceTarget(workspace, target, { mustExist = false } = {}) { + const rawTarget = String(target || ".").trim(); + if (!rawTarget) return { blocked: true, reason: "missing target path" }; + if (isForbiddenPath(rawTarget)) { + return { blocked: true, reason: "security_blocked: target path may expose secrets or forbidden runtime material" }; + } + const resolved = path.resolve(workspace, rawTarget); + let realWorkspace = workspace; + let realResolved = resolved; + try { + realWorkspace = realpathSync(workspace); + realResolved = existsSync(resolved) ? realpathSync(resolved) : path.resolve(realWorkspace, path.relative(workspace, resolved)); + } catch { + realWorkspace = path.resolve(workspace); + realResolved = path.resolve(resolved); + } + if (!isPathInside(realResolved, realWorkspace)) { + return { blocked: true, reason: "security_blocked: target path is outside the runner workspace" }; + } + const relative = path.relative(realWorkspace, realResolved) || "."; + if (isForbiddenPath(relative)) { + return { blocked: true, reason: "security_blocked: target path is not allowed" }; + } + return { + path: realResolved, + relative, + async check() { + if (!mustExist) return null; + try { + await stat(realResolved); + return null; + } catch { + return "target path is not readable"; + } + } + }; +} + +function isPathInside(child, parent) { + const relative = path.relative(parent, child); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function isForbiddenPath(value) { + const normalized = String(value ?? "").replaceAll("\\", "/").toLowerCase(); + return /(^|\/)(?:\.env(?:\.|$)|\.npmrc$|\.pypirc$|id_rsa$|id_ed25519$|kubeconfig$|k3s\.yaml$|credentials?$|secrets?$|token(?:s)?$|database-url$)/u.test(normalized) || + /(?:secret|token|password|passwd|private[_-]?key|openai_api_key|database_url|kubeconfig)/u.test(normalized); +} + +function blockedToolCall({ name, type, workspace, traceId, reason }) { + return { + id: `tool_${randomUUID()}`, + type, + name, + status: "blocked", + cwd: workspace, + exitCode: 1, + stdout: "", + stderrSummary: `security_blocked: ${redactText(reason)}`, + outputTruncated: false, + traceId + }; +} + +function safeDisplayPath(value) { + return redactText(String(value ?? ".")).replace(/\s+/gu, " "); +} + function buildCodexUserPrompt(message, { conversationId, traceId }) { return [ `conversationId: ${conversationId}`, @@ -1114,6 +1631,10 @@ function accessSyncBoolean(target, mode) { } } +function pathReadableSync(targetPath) { + return accessSyncBoolean(targetPath, fsConstants.R_OK); +} + function codexBinaryState(command, env = process.env) { const resolvedPath = commandPathSync(command, env); const present = Boolean(resolvedPath); @@ -1124,22 +1645,71 @@ function codexBinaryState(command, env = process.env) { error: null, exitCode: null }; + const nativeDependency = present ? codexNativeDependencyState(resolvedPath) : { + present: false, + path: null, + evidence: "Codex CLI command was not present, so native dependency was not checked." + }; return { command, present, binaryPresent: present, path: resolvedPath, + executable: versionProbe.ok, version: versionProbe.version, versionDetected: versionProbe.ok, versionProbe, + nativeDependencyPresent: nativeDependency.present, + nativeDependencyPath: nativeDependency.path, + nativeDependencyEvidence: nativeDependency.evidence, nextEvidence: present - ? "codex --version was probed without printing secrets; stdio protocol readiness is checked separately." + ? versionProbe.ok + ? "codex --version was probed without printing secrets; stdio protocol readiness is checked separately." + : `Codex CLI command ${command} exists but --version did not complete successfully.` : `Install/provide a repo-controlled Codex CLI binary on PATH or set HWLAB_CODE_AGENT_CODEX_COMMAND to an approved binary path; checked command=${command}.`, secretMaterialRead: false, valuesRedacted: true }; } +function codexNativeDependencyState(resolvedCommandPath) { + const candidates = []; + let resolved = path.resolve(resolvedCommandPath); + try { + resolved = realpathSync(resolved); + } catch { + // Fall back to the resolved command path; the binary blocker will report executability. + } + const packageRoot = findPackageRoot(resolved); + if (packageRoot) { + candidates.push( + path.join(packageRoot, "..", "codex-linux-x64", "vendor", "x86_64-unknown-linux-musl", "codex", "codex"), + path.join(packageRoot, "..", "codex-linux-arm64", "vendor", "aarch64-unknown-linux-musl", "codex", "codex"), + path.join(packageRoot, "vendor", "x86_64-unknown-linux-musl", "codex", "codex"), + path.join(packageRoot, "vendor", "aarch64-unknown-linux-musl", "codex", "codex") + ); + } + const existing = candidates.find((candidate) => existsSync(candidate) && accessSyncBoolean(candidate, fsConstants.X_OK)) ?? null; + return { + present: Boolean(existing), + path: existing, + evidence: existing + ? "Native @openai/codex executable dependency exists and is executable." + : "Expected native @openai/codex executable dependency was not found next to the CLI package." + }; +} + +function findPackageRoot(resolvedCommandPath) { + let current = path.dirname(resolvedCommandPath); + for (let depth = 0; depth < 8; depth += 1) { + if (existsSync(path.join(current, "package.json"))) return current; + const next = path.dirname(current); + if (next === current) return null; + current = next; + } + return null; +} + function commandPathSync(command, env = process.env) { if (!command) return null; if (command.includes("/") || command.includes("\\")) { @@ -1208,6 +1778,35 @@ function protocolState({ command, binary, supervisor, toolsObserved }) { }; } +function cachedProbeTools({ 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; +} + +function protocolProbeSummary({ command, workspace, codexHome, probe }) { + if (!probe || probe.command !== command || probe.workspace !== workspace || probe.codexHome !== codexHome) { + return { + status: "not_run", + ready: false, + toolsObserved: [], + secretMaterialRead: false, + valuesRedacted: true + }; + } + return { + status: probe.status, + ready: probe.ready === true, + toolsObserved: Array.isArray(probe.toolsObserved) ? [...probe.toolsObserved] : [], + startedAt: probe.startedAt, + finishedAt: probe.finishedAt, + error: probe.error ?? null, + secretMaterialRead: false, + valuesRedacted: true + }; +} + function lifecycleState({ supervisor, idleTimeoutMs, maxSessions, activeSessions }) { const ready = supervisor.configured === true; return { @@ -1280,9 +1879,11 @@ function runtimeContract({ status: ready ? "ready" : "blocked", ready, binary: { - status: binary.present ? "present" : "missing", + status: binary.present && binary.executable && binary.nativeDependencyPresent ? "present" : binary.present ? "blocked" : "missing", command: binary.command, present: binary.present, + executable: binary.executable === true, + nativeDependencyPresent: binary.nativeDependencyPresent === true, version: binary.version, versionDetected: binary.versionDetected, nextEvidence: binary.nextEvidence @@ -1449,6 +2050,15 @@ function tailText(value, maxLength = 1200) { return text.slice(text.length - maxLength); } +function boundToolOutput(value, maxLength = CODEX_STDIO_TOOL_OUTPUT_LIMIT) { + const text = String(value ?? ""); + if (text.length <= maxLength) return { text, truncated: false }; + return { + text: `${text.slice(0, maxLength)}\n[output truncated at ${maxLength} bytes]`, + truncated: true + }; +} + function redactText(value) { return String(value ?? "") .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer ***") diff --git a/internal/cloud/gate-diagnostics.mjs b/internal/cloud/gate-diagnostics.mjs index fa90274c..963aff22 100644 --- a/internal/cloud/gate-diagnostics.mjs +++ b/internal/cloud/gate-diagnostics.mjs @@ -64,7 +64,7 @@ async function observeHealth(options, observedAt) { const dbProbe = await buildDbRuntimeReadiness(env, options.dbProbe); const runtime = await runtimeReadiness(options.runtimeStore); const db = applyRuntimeDbReadinessLayers(dbProbe, runtime); - const codeAgent = describeCodeAgentAvailability(env, options); + const codeAgent = await describeCodeAgentAvailability(env, options); const readiness = buildCloudApiReadiness({ db, codeAgent, runtime }); const commitId = env.HWLAB_COMMIT_ID || env.HWLAB_GIT_SHA || "unknown"; const imageTag = env.HWLAB_IMAGE_TAG || shortHash(commitId); diff --git a/internal/cloud/health-contract.mjs b/internal/cloud/health-contract.mjs index db98fb8f..47a1f240 100644 --- a/internal/cloud/health-contract.mjs +++ b/internal/cloud/health-contract.mjs @@ -207,12 +207,15 @@ function buildRuntimeDurabilityReadiness({ db, runtime, runtimeReady, runtimeBlo function buildProviderReadiness(codeAgent = {}) { const fallback = codeAgent?.fallback ?? {}; const fallbackReady = fallback.ready === true || fallback.status === "available"; + const tokenBoundary = codeAgent?.codexStdioFeasibility?.tokenBoundary ?? codeAgent?.codexStdio?.tokenBoundary ?? null; + const stdioTokenBoundaryReady = codeAgent?.mode === "codex-stdio" && tokenBoundary?.present === true && codeAgent?.egress?.directPublicOpenAi !== true; + const ready = fallbackReady || stdioTokenBoundaryReady; const missingEnv = Array.isArray(codeAgent?.missingEnv) ? [...codeAgent.missingEnv] : []; - const blocker = fallbackReady ? null : providerBlockerCode(codeAgent, fallback); + const blocker = ready ? null : providerBlockerCode(codeAgent, fallback); return { - status: fallbackReady ? "ready" : "blocked", - ready: fallbackReady, - configured: fallbackReady, + status: ready ? "ready" : "blocked", + ready, + configured: ready, provider: codeAgent?.provider ?? fallback.provider ?? "unknown", model: codeAgent?.model ?? fallback.model ?? "unknown", backend: codeAgent?.backend ?? fallback.backend ?? "unknown", @@ -275,6 +278,8 @@ function buildCodexStdioReadiness(codeAgent = {}) { blockerCodes, sourceIssue: stdio.sourceIssue ?? "pikasTech/HWLAB#275", commandPresent: stdio.binaryOnPath === true, + commandExecutable: stdio.binary?.executable === true, + nativeDependencyPresent: stdio.binary?.nativeDependencyPresent === true, supervisorConfigured: stdio.supervisor?.configured === true, workspaceReady: stdio.workspaceState?.exists === true && stdio.workspaceState?.readable === true, tokenBoundaryPresent: stdio.tokenBoundary?.present === true, diff --git a/internal/cloud/json-rpc.mjs b/internal/cloud/json-rpc.mjs index df287dd3..50603d51 100644 --- a/internal/cloud/json-rpc.mjs +++ b/internal/cloud/json-rpc.mjs @@ -228,7 +228,7 @@ export function createResponseMeta(requestMeta = {}) { async function handleSystemHealth(params, envelope, context) { const env = context.env ?? process.env; const db = await buildDbRuntimeReadiness(env, context.dbProbe); - const codeAgent = describeCodeAgentAvailability(env, context); + const codeAgent = await describeCodeAgentAvailability(env, context); const runtime = await runtimeReadiness(getRuntimeStore(context)); const readiness = buildCloudApiReadiness({ db, codeAgent, runtime }); return { @@ -248,7 +248,7 @@ async function handleSystemHealth(params, envelope, context) { async function handleAdapterDescribe(params, envelope, context) { const env = context.env ?? process.env; const db = await buildDbRuntimeReadiness(env, context.dbProbe); - const codeAgent = describeCodeAgentAvailability(env, context); + const codeAgent = await describeCodeAgentAvailability(env, context); const runtime = await runtimeReadiness(getRuntimeStore(context)); const readiness = buildCloudApiReadiness({ db, codeAgent, runtime }); return { diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index db86fac0..debc4edb 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -1,5 +1,6 @@ import { createServer } from "node:http"; import { randomUUID } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -78,6 +79,7 @@ const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([ export function createCloudApiServer(options = {}) { const env = options.env ?? process.env; + ensureCodeAgentRuntimeBase(env); const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env }); const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({ staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000), @@ -98,15 +100,56 @@ export function createCloudApiServer(options = {}) { }); } +export function ensureCodeAgentRuntimeBase(env = process.env) { + const workspace = env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || env.HWLAB_CODE_AGENT_WORKSPACE || "/workspace/hwlab"; + const codexHome = env.CODEX_HOME || env.HWLAB_CODE_AGENT_CODEX_HOME || "/codex-home"; + const appCodexCommand = "/app/node_modules/.bin/codex"; + const localCodexCommand = path.join(process.cwd(), "node_modules", ".bin", "codex"); + const codexCommand = existsSync(appCodexCommand) + ? appCodexCommand + : existsSync(localCodexCommand) + ? localCodexCommand + : appCodexCommand; + env.HWLAB_CODE_AGENT_WORKSPACE ||= workspace; + env.HWLAB_CODE_AGENT_CODEX_WORKSPACE ||= workspace; + 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.CODEX_HOME ||= codexHome; + env.HWLAB_CODE_AGENT_CODEX_COMMAND ||= codexCommand; + env.HWLAB_CODE_AGENT_SKILLS_DIRS ||= "/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills"; + + for (const target of [path.dirname(workspace), codexHome]) { + try { + mkdirSync(target, { recursive: true }); + } catch { + // Runtime health reports the concrete missing/writable blocker. + } + } + try { + if (!existsSync(workspace)) { + if (workspace === "/workspace/hwlab" && existsSync("/app")) { + symlinkSync("/app", workspace, "dir"); + } else { + mkdirSync(workspace, { recursive: true }); + } + } + lstatSync(workspace); + } catch { + // Runtime health reports the concrete missing/writable blocker. + } +} + export async function buildHealthPayload(options = {}) { const serviceId = CLOUD_API_SERVICE_ID; const env = options.env ?? process.env; + ensureCodeAgentRuntimeBase(env); const metadata = buildMetadataFromEnv(env, { serviceId, fallbackImageRepository: "ghcr.io/pikastech/hwlab-cloud-api" }); const dbProbe = await buildDbRuntimeReadiness(env, options.dbProbe); - const codeAgent = describeCodeAgentAvailability(env, options); + const codeAgent = await describeCodeAgentAvailability(env, options); const runtime = await runtimeReadiness(options.runtimeStore ?? createConfiguredCloudRuntimeStore({ ...options, env })); const db = applyRuntimeDbReadinessLayers(dbProbe, runtime); const readiness = buildCloudApiReadiness({ db, codeAgent, runtime }); @@ -216,7 +259,7 @@ async function handleRpcHttpRequest(request, response, options) { async function handleRestAdapter(request, response, url, options) { if (request.method === "GET" && url.pathname === "/v1") { const dbProbe = await buildDbRuntimeReadiness(options.env ?? process.env, options.dbProbe); - const codeAgent = describeCodeAgentAvailability(options.env ?? process.env, options); + const codeAgent = await describeCodeAgentAvailability(options.env ?? process.env, options); const runtime = await runtimeReadiness(options.runtimeStore); const db = applyRuntimeDbReadinessLayers(dbProbe, runtime); const readiness = buildCloudApiReadiness({ db, codeAgent, runtime }); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 99386aa3..61572adb 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -117,14 +117,15 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthPayload.codeAgent.capabilityLevel, "read-only-session-tools"); assert.equal(healthPayload.codeAgent.sessionRegistry.status, "available"); assert.equal(healthPayload.codeAgent.longLivedSessionGate.status, "blocked"); - assert.ok(healthPayload.codeAgent.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_stdio_supervisor_disabled")); + assert.ok(healthPayload.codeAgent.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_cli_binary_missing")); + assert.ok(healthPayload.codeAgent.longLivedSessionGate.blockers.some((blocker) => blocker.code === "provider_token_boundary")); assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.binary.status, "missing"); assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.stdioProtocol.status, "blocked"); - assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.lifecycleSupervisor.status, "blocked"); - assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.cancelReapTraceReadiness.status, "blocked"); + assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.lifecycleSupervisor.status, "present"); + assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.cancelReapTraceReadiness.status, "ready"); assert.ok(healthPayload.codeAgent.codexStdio.blockerCodes.includes("codex_cli_binary_missing")); - assert.ok(healthPayload.codeAgent.codexStdio.blockerCodes.includes("runner_lifecycle_missing")); assert.ok(healthPayload.codeAgent.codexStdio.blockerCodes.includes("stdio_protocol_not_wired")); + assert.ok(healthPayload.codeAgent.codexStdio.blockerCodes.includes("provider_token_boundary")); assert.deepEqual(healthPayload.codeAgent.missingEnv, ["OPENAI_API_KEY"]); assert.equal(healthPayload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider"); assert.equal(healthPayload.codeAgent.secretRefs[0].secretKey, "openai-api-key"); @@ -1133,11 +1134,252 @@ function m3GatewayStatus({ gatewayId, gatewaySessionId, boxId, resourceId }) { }; } +function codexStdioReadyFixture({ workspace, codexHome }) { + return { + kind: "codex-mcp-stdio-runner", + provider: "codex-stdio", + backend: "hwlab-cloud-api/codex-mcp-stdio", + status: "feasible", + ready: true, + startupReady: true, + canStartLongLivedCodexStdio: true, + command: path.join(process.cwd(), "node_modules", ".bin", "codex"), + binaryOnPath: true, + binary: { + present: true, + executable: true, + nativeDependencyPresent: true, + versionDetected: true + }, + workspace, + workspaceState: { exists: true, readable: true, writable: true, writeRequired: true }, + codexHome, + codexHomeState: { exists: true, readable: true, writable: true, writeRequired: true }, + sandbox: "workspace-write", + enabled: true, + supervisor: { configured: true, mode: "repo-owned-node-supervisor" }, + tokenBoundary: { present: true, secretMaterialRead: false, valuesRedacted: true }, + egress: { configured: true, directPublicOpenAi: false, valueRedacted: true }, + protocol: { + status: "wired", + wired: true, + requiredTools: ["codex", "codex-reply"], + toolsObserved: ["codex", "codex-reply"], + missingTools: [], + command: "codex mcp-server" + }, + stdioProtocol: { + status: "wired", + wired: true, + requiredTools: ["codex", "codex-reply"], + toolsObserved: ["codex", "codex-reply"], + missingTools: [], + command: "codex mcp-server" + }, + sessionLifecycle: { + status: "present", + present: true, + create: true, + reuse: true, + cancel: true, + reap: true, + traceCapture: true, + idleTimeoutMs: 1800000 + }, + lifecycleSupervisor: { + status: "present", + present: true, + create: true, + reuse: true, + cancel: true, + reap: true, + traceCapture: true, + idleTimeoutMs: 1800000 + }, + runtimeContract: { + status: "ready", + ready: true, + binary: { + status: "present", + present: true, + executable: true, + nativeDependencyPresent: true, + versionDetected: true + }, + stdioProtocol: { + status: "wired", + wired: true, + requiredTools: ["codex", "codex-reply"], + toolsObserved: ["codex", "codex-reply"], + missingTools: [] + }, + lifecycleSupervisor: { + status: "present", + present: true, + create: true, + reuse: true, + cancel: true, + reap: true, + traceCapture: true, + idleTimeoutMs: 1800000 + }, + workspaceMount: { + path: workspace, + status: "ready", + mounted: true, + readable: true, + writable: true, + sandbox: "workspace-write" + }, + codexHome: { + path: codexHome, + status: "ready", + exists: true, + readable: true, + writable: true + }, + cancelReapTraceReadiness: { + status: "ready", + cancel: true, + reap: true, + traceCapture: true, + idleTimeout: true + } + }, + blockers: [], + blockerCodes: [], + safety: { secretMaterialRead: false, valuesRedacted: true } + }; +} + +function codexStdioChatFixture({ workspace, codexHome, params }) { + const traceId = params.traceId; + const session = { + sessionId: "ses_server_stdio_pwd", + conversationId: params.conversationId, + status: "idle", + workspace, + sandbox: "workspace-write", + runnerKind: "codex-mcp-stdio-runner", + sessionMode: "codex-mcp-stdio-long-lived", + capabilityLevel: "long-lived-codex-stdio-session", + implementationType: "repo-owned-codex-mcp-stdio-session", + createdAt: "2026-05-23T00:00:00.000Z", + updatedAt: "2026-05-23T00:00:00.000Z", + idleTimeoutMs: 1800000, + expiresAt: "2026-05-23T00:30:00.000Z", + lastTraceId: traceId, + turn: 1, + threadId: "thread_server_stdio_pwd", + reused: false, + durable: true, + longLivedSession: true, + codexStdio: true, + writeCapable: true, + secretMaterialStored: false, + valuesRedacted: true + }; + const feasibility = codexStdioReadyFixture({ workspace, codexHome }); + return { + provider: "codex-stdio", + model: "gpt-test", + backend: "hwlab-cloud-api/codex-mcp-stdio", + content: `stdio pwd\nrunner tool evidence: pwd:completed\n${workspace}`, + workspace, + sandbox: "workspace-write", + session, + sessionMode: "codex-mcp-stdio-long-lived", + sessionReuse: { + conversationId: params.conversationId, + sessionId: session.sessionId, + threadId: session.threadId, + mapped: true, + reused: false, + turn: 1, + previousTurns: 0, + workspace, + status: "idle", + idleTimeoutMs: 1800000 + }, + implementationType: "repo-owned-codex-mcp-stdio-session", + runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], + codexStdioFeasibility: feasibility, + longLivedSessionGate: { + status: "pass", + pass: true, + provider: "codex-stdio", + runnerKind: "codex-mcp-stdio-runner", + sessionMode: "codex-mcp-stdio-long-lived", + implementationType: "repo-owned-codex-mcp-stdio-session", + blockers: [] + }, + toolCalls: [{ + id: "tool_server_stdio_pwd", + type: "workspace-read", + name: "pwd", + status: "completed", + cwd: workspace, + command: "pwd", + exitCode: 0, + stdout: workspace, + stderrSummary: "", + outputTruncated: false, + traceId + }], + skills: { + status: "not_requested", + items: [], + count: 0, + blockers: [] + }, + runner: { + kind: "codex-mcp-stdio-runner", + provider: "codex-stdio", + backend: "hwlab-cloud-api/codex-mcp-stdio", + workspace, + sandbox: "workspace-write", + session: "codex-mcp-stdio-long-lived", + sessionMode: "codex-mcp-stdio-long-lived", + sessionId: session.sessionId, + implementationType: "repo-owned-codex-mcp-stdio-session", + codexStdio: true, + longLivedSession: true, + durableSession: true, + writeCapable: true, + readOnly: false, + capabilityLevel: "long-lived-codex-stdio-session" + }, + runnerTrace: { + traceId, + runnerKind: "codex-mcp-stdio-runner", + workspace, + sandbox: "workspace-write", + sessionMode: "codex-mcp-stdio-long-lived", + sessionId: session.sessionId, + sessionStatus: "idle", + turn: 1, + implementationType: "repo-owned-codex-mcp-stdio-session", + events: ["stdio:acquire", "session:created", "stdio:ready", "tool:pwd:completed"], + outputTruncated: false, + valuesPrinted: false + }, + capabilityLevel: "long-lived-codex-stdio-session", + providerTrace: { + transport: "stdio", + protocol: "mcp", + command: "codex mcp-server", + toolName: "codex", + threadId: "thread_server_stdio_pwd", + valuesPrinted: false + } + }; +} + test("cloud api /v1 describes Code Agent provider blocker without leaking secret values", async () => { const server = createCloudApiServer({ env: { PATH: "", - HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex" } @@ -1150,10 +1392,10 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret assert.equal(response.status, 200); const payload = await response.json(); assert.equal(payload.codeAgent.endpoint, "POST /v1/agent/chat"); - assert.equal(payload.codeAgent.provider, "openai-responses"); + assert.equal(payload.codeAgent.provider, "codex-stdio"); assert.equal(payload.codeAgent.model, "gpt-test"); - assert.equal(payload.codeAgent.backend, "hwlab-cloud-api/openai-responses"); - assert.equal(payload.codeAgent.mode, "openai"); + assert.equal(payload.codeAgent.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.codeAgent.mode, "codex-stdio"); assert.equal(payload.codeAgent.status, "partial"); assert.match(payload.codeAgent.blocker, /Codex CLI command/u); assert.equal(payload.codeAgent.reason, "codex_cli_binary_missing"); @@ -1162,7 +1404,7 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret assert.equal(payload.codeAgent.capabilityLevel, "read-only-session-tools"); assert.equal(payload.codeAgent.sessionRegistry.status, "available"); assert.equal(payload.codeAgent.longLivedSessionGate.status, "blocked"); - assert.deepEqual(payload.codeAgent.missingEnv, ["OPENAI_API_KEY", "HWLAB_CODE_AGENT_OPENAI_BASE_URL"]); + assert.deepEqual(payload.codeAgent.missingEnv, []); assert.equal(payload.codeAgent.secretRefs[0].env, "OPENAI_API_KEY"); assert.equal(payload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider"); assert.equal(payload.codeAgent.secretRefs[0].secretKey, "openai-api-key"); @@ -1173,7 +1415,7 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret assert.equal(payload.codeAgent.egress.valueRedacted, true); assert.equal(payload.codeAgent.safety.secretMaterialRead, false); assert.match(payload.codeAgent.summary, /受控只读 runner/u); - assert.match(payload.codeAgent.summary, /hwlab-code-agent-provider\/openai-api-key/u); + assert.match(payload.codeAgent.summary, /long-lived Codex stdio/u); assert.equal(JSON.stringify(payload).includes("sk-"), false); } finally { await new Promise((resolve, reject) => { @@ -1186,7 +1428,7 @@ test("cloud api /v1 describes Code Agent egress blocker without leaking API key" const server = createCloudApiServer({ env: { OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_OPENAI_BASE_URL: "https://api.openai.com/v1/responses" } @@ -1211,12 +1453,12 @@ test("cloud api /v1 describes Code Agent egress blocker without leaking API key" } }); -test("cloud api health reports provider and durable DB ready while codex stdio remains current blocker", async () => { +test("cloud api health reports provider, durable DB, and codex stdio ready when runtime contract passes", async () => { const server = createCloudApiServer({ env: { PATH: process.env.PATH, OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:17680/v1/responses", HWLAB_CLOUD_DB_URL: "postgres://hwlab_test:password@db.internal.local:5432/hwlab", @@ -1251,7 +1493,7 @@ test("cloud api health reports provider and durable DB ready while codex stdio r const response = await fetch(`http://127.0.0.1:${port}/health/live`); assert.equal(response.status, 200); const payload = await response.json(); - assert.equal(payload.status, "degraded"); + assert.equal(payload.status, "ok"); assert.equal(payload.db.ready, true); assert.equal(payload.db.liveDbEvidence, true); assert.equal(payload.runtime.ready, true); @@ -1261,15 +1503,22 @@ test("cloud api health reports provider and durable DB ready while codex stdio r assert.equal(payload.readiness.provider.blocker, null); assert.equal(payload.readiness.dbDurable.status, "ready"); assert.equal(payload.readiness.dbDurable.ready, true); - assert.equal(payload.readiness.sessionRunner.status, "read_only_long_lived_ready"); + assert.equal(payload.readiness.sessionRunner.status, "codex_stdio_ready"); assert.equal(payload.readiness.sessionRunner.ready, true); - assert.equal(payload.readiness.codexStdio.status, "blocked"); - assertCodexStdioFeasibilityBlocker(payload.readiness.codexStdio.blocker); - assert.deepEqual(payload.readiness.codeAgent.currentBlockers, [payload.readiness.codexStdio.blocker]); + assert.equal(payload.readiness.sessionRunner.codexStdio, true); + assert.equal(payload.readiness.sessionRunner.writeCapable, true); + assert.equal(payload.readiness.codexStdio.status, "ready"); + assert.equal(payload.readiness.codexStdio.ready, true); + assert.deepEqual(payload.readiness.codeAgent.currentBlockers, []); assert.equal(payload.readiness.codeAgent.providerReady, true); assert.equal(payload.readiness.codeAgent.durableDbReady, true); assert.equal(payload.readiness.codeAgent.sessionRunnerReady, true); - assert.equal(payload.readiness.codeAgent.codexStdioFeasible, false); + assert.equal(payload.readiness.codeAgent.codexStdioFeasible, true); + assert.equal(payload.codeAgent.ready, true); + assert.equal(payload.codeAgent.status, "codex-stdio-feasible"); + assert.equal(payload.codeAgent.longLivedSessionGate.status, "pass"); + assert.equal(payload.codeAgent.codexStdio.runtimeContract.binary.status, "present"); + assert.deepEqual(payload.codeAgent.codexStdio.protocol.toolsObserved, ["codex", "codex-reply"]); assert.equal(JSON.stringify(payload).includes("provider_unavailable"), false); assert.equal(JSON.stringify(payload).includes("dns_resolution_failed"), false); assert.equal(JSON.stringify(payload).includes("runtime_durable_adapter_auth_blocked"), false); @@ -1366,16 +1615,31 @@ test("cloud api /v1/agent/chat parse and params errors use structured blocker en } }); -test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence", async () => { +test("cloud api /v1/agent/chat runs Codex stdio pwd with session and workspace evidence", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-workspace-")); + const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-codex-home-")); const server = createCloudApiServer({ env: { PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_PROVIDER: "openai", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", OPENAI_API_KEY: "test-openai-key-material", HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" + }, + codexStdioManager: { + describe() { + return codexStdioReadyFixture({ workspace, codexHome }); + }, + async probe() { + return codexStdioReadyFixture({ workspace, codexHome }); + }, + async chat(params = {}) { + return codexStdioChatFixture({ workspace, codexHome, params }); + }, + cancel() {}, + reapIdle() {} } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -1397,41 +1661,39 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence const payload = await response.json(); validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "codex-readonly-runner"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-readonly-runner"); - assert.equal(payload.capabilityLevel, "read-only-session-tools"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); assert.equal(payload.workspace, workspace); - assert.equal(payload.sandbox, "read-only"); + assert.equal(payload.sandbox, "workspace-write"); assert.equal(payload.session.status, "idle"); assert.equal(payload.session.workspace, workspace); - assert.equal(payload.session.sandbox, "read-only"); + assert.equal(payload.session.sandbox, "workspace-write"); assert.equal(payload.session.lastTraceId, "trc_server-test-runner-pwd"); assert.equal(typeof payload.session.idleTimeoutMs, "number"); - assert.equal(payload.runner.kind, "hwlab-readonly-runner"); + assert.equal(payload.runner.kind, "codex-mcp-stdio-runner"); assert.equal(payload.runner.longLivedSession, true); - assert.equal(payload.runner.codexStdio, false); - assert.equal(payload.runner.writeCapable, false); - assert.equal(payload.runner.durableSession, false); - assert.equal(payload.runner.session, "controlled-readonly-session-registry"); - assert.equal(payload.sessionMode, "controlled-readonly-session-registry"); - assert.equal(payload.implementationType, "controlled-readonly-session-registry"); + assert.equal(payload.runner.codexStdio, true); + assert.equal(payload.runner.writeCapable, true); + assert.equal(payload.runner.durableSession, true); + assert.equal(payload.runner.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(payload.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(payload.implementationType, "repo-owned-codex-mcp-stdio-session"); assert.equal(payload.sessionReuse.reused, false); assert.equal(payload.sessionReuse.turn, 1); assert.equal(payload.sessionReuse.status, "idle"); - assert.ok(payload.runnerLimitations.includes("not-codex-stdio")); - assert.ok(payload.runnerLimitations.includes("process-local-session-registry")); - assert.ok(payload.codexStdioFeasibility.blockers.some((blocker) => blocker.code === "codex_stdio_supervisor_disabled")); - assert.ok(payload.codexStdioFeasibility.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired")); - assert.equal(payload.codexStdioFeasibility.runtimeContract.stdioProtocol.status, "blocked"); - assert.equal(payload.codexStdioFeasibility.runtimeContract.lifecycleSupervisor.status, "blocked"); - assert.equal(payload.longLivedSessionGate.status, "blocked"); - assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_stdio_blocked_readonly_session_available")); + assert.ok(payload.runnerLimitations.includes("hardware-control-via-cloud-api-only")); + assert.equal(payload.codexStdioFeasibility.ready, true); + assert.equal(payload.codexStdioFeasibility.runtimeContract.stdioProtocol.status, "wired"); + assert.equal(payload.codexStdioFeasibility.runtimeContract.lifecycleSupervisor.status, "present"); + assert.equal(payload.longLivedSessionGate.status, "pass"); + assert.equal(payload.longLivedSessionGate.pass, true); assert.equal(payload.toolCalls[0].name, "pwd"); assert.equal(payload.toolCalls[0].status, "completed"); assert.equal(payload.toolCalls[0].stdout, workspace); assert.equal(payload.skills.status, "not_requested"); - assert.equal(payload.runnerTrace.runnerKind, "hwlab-readonly-runner"); - assert.equal(payload.runnerTrace.sessionMode, "controlled-readonly-session-registry"); + assert.equal(payload.runnerTrace.runnerKind, "codex-mcp-stdio-runner"); + assert.equal(payload.runnerTrace.sessionMode, "codex-mcp-stdio-long-lived"); assert.match(payload.reply.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))); assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); } finally { @@ -1983,6 +2245,7 @@ test("cloud api /v1/agent/chat uses streaming OpenAI Responses and parses SSE te env: { OPENAI_API_KEY: "test-openai-key-material", HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "true", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_OPENAI_BASE_URL: `http://127.0.0.1:${providerPort}/v1/responses` } @@ -2084,6 +2347,7 @@ test("cloud api /v1/agent/chat keeps delayed provider failure structured beyond env: { OPENAI_API_KEY: "test-openai-key-material", HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "true", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1/provider-fixture" }, @@ -2148,6 +2412,7 @@ test("cloud api /v1/agent/chat reports provider timeout as failed without a repl env: { OPENAI_API_KEY: "test-openai-key-material", HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "true", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_OPENAI_BASE_URL: `http://127.0.0.1:${providerPort}/v1/responses` }, @@ -2210,6 +2475,7 @@ test("cloud api /v1/agent/chat reports OpenAI provider 502 and 503 as failed blo env: { OPENAI_API_KEY: "test-openai-key-material", HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "true", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_OPENAI_BASE_URL: `http://127.0.0.1:${providerPort}/v1/responses` } diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index 7c80e7b4..1780f4be 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -1,12 +1,16 @@ #!/usr/bin/env node import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import http from "node:http"; import https from "node:https"; +import os from "node:os"; +import path from "node:path"; import { handleCodeAgentChat, validateCodeAgentChatSchema } from "../internal/cloud/code-agent-chat.mjs"; +import { createCodexStdioSessionManager } from "../internal/cloud/codex-stdio-session.mjs"; import { classifyCodexRunnerCapability, classifyCodeAgentChatReadiness, @@ -220,6 +224,136 @@ async function runLocalContractSmoke() { assert.equal(runnerCapability.blocker, "controlled-readonly-not-long-lived"); logOk("read-only runner pwd uses a reusable session but remains partial capability"); + const stdioWorkspaceRoot = await mkdtemp(path.join(os.tmpdir(), `hwlab-code-agent-stdio-smoke-${process.pid}-`)); + const stdioWorkspace = path.join(stdioWorkspaceRoot, "workspace"); + const stdioCodexHome = path.join(stdioWorkspaceRoot, "codex-home"); + const stdioSkillsDir = path.join(stdioWorkspaceRoot, "skills"); + await mkdir(stdioWorkspace, { recursive: true }); + 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(stdioSkillsDir, "aaa-stdio-smoke-skill", "SKILL.md"), [ + "---", + "name: aaa-stdio-smoke-skill", + "description: Stdio smoke skill summary.", + "---", + "", + "# Stdio Smoke" + ].join("\n")); + try { + 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) { + return { + structuredContent: { + threadId: "thread_code_agent_chat_smoke_stdio", + content: `${name} fixture reply for ${toolArgs?.threadId ?? "new-thread"}` + } + }; + }, + close() {} + }) + }); + const stdioEnv = { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: stdioCodexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_CODEX_WORKSPACE: stdioWorkspace, + HWLAB_CODE_AGENT_CODEX_SANDBOX: "workspace-write", + HWLAB_CODE_AGENT_SKILLS_DIRS: stdioSkillsDir, + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" + }; + const stdioPwd = await handleCodeAgentChat( + { + conversationId: "cnv_code-agent-chat-stdio", + traceId: "trc_code-agent-chat-stdio-pwd", + message: "请 pwd、ls .、列出可用 skills,并做一次 workspace 写入读取清理 smoke" + }, + { + now: () => "2026-05-22T00:02:10.000Z", + codexStdioManager: stdioManager, + env: stdioEnv + } + ); + 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.capabilityLevel, "long-lived-codex-stdio-session"); + assert.equal(stdioPwd.workspace, stdioWorkspace); + assert.equal(stdioPwd.sandbox, "workspace-write"); + assert.equal(stdioPwd.runner.codexStdio, true); + assert.equal(stdioPwd.runner.writeCapable, true); + assert.equal(stdioPwd.runner.durableSession, true); + assert.equal(stdioPwd.session.longLivedSession, true); + assert.equal(stdioPwd.session.codexStdio, true); + assert.equal(stdioPwd.sessionReuse.reused, false); + assert.equal(stdioPwd.sessionReuse.turn, 1); + assert.equal(stdioPwd.codexStdioFeasibility.ready, true); + assert.equal(stdioPwd.codexStdioFeasibility.runtimeContract.stdioProtocol.status, "wired"); + assert.equal(stdioPwd.codexStdioFeasibility.runtimeContract.lifecycleSupervisor.status, "present"); + assert.equal(stdioPwd.codexStdioFeasibility.runtimeContract.workspaceMount.status, "ready"); + assert.equal(stdioPwd.codexStdioFeasibility.runtimeContract.codexHome.status, "ready"); + assert.equal(stdioPwd.longLivedSessionGate.status, "pass"); + assert.equal(stdioPwd.longLivedSessionGate.pass, true); + assert.ok(stdioPwd.toolCalls.some((call) => call.name === "codex" && call.status === "completed")); + assert.ok(stdioPwd.toolCalls.some((call) => call.name === "pwd" && call.status === "completed")); + assert.ok(stdioPwd.toolCalls.some((call) => call.name === "ls" && call.status === "completed")); + assert.ok(stdioPwd.toolCalls.some((call) => call.name === "skills.discover" && call.status === "completed")); + assert.ok(stdioPwd.toolCalls.some((call) => call.name === "workspace.smoke.write" && call.status === "completed")); + assert.ok(stdioPwd.toolCalls.some((call) => call.name === "workspace.smoke.read" && call.status === "completed")); + assert.ok(stdioPwd.toolCalls.some((call) => call.name === "workspace.smoke.cleanup" && 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.ok(stdioPwd.runnerTrace.events.includes("tool:workspace.pwd:completed")); + assert.ok(stdioPwd.runnerTrace.events.includes("tool:workspace.smoke.cleanup:completed")); + assert.equal(classifyCodexRunnerCapability(stdioPwd, { httpStatus: 200 }).capabilityPass, true); + + const stdioSecondTurn = await handleCodeAgentChat( + { + conversationId: "cnv_code-agent-chat-stdio", + traceId: "trc_code-agent-chat-stdio-reuse", + message: "继续复用同一个 session 并 ls ." + }, + { + now: () => "2026-05-22T00:02:11.000Z", + codexStdioManager: stdioManager, + env: stdioEnv + } + ); + validateCodeAgentChatSchema(stdioSecondTurn); + assert.equal(stdioSecondTurn.status, "completed"); + assert.equal(stdioSecondTurn.provider, "codex-stdio"); + assert.equal(stdioSecondTurn.sessionId, stdioPwd.sessionId); + assert.equal(stdioSecondTurn.session.sessionId, stdioPwd.session.sessionId); + assert.equal(stdioSecondTurn.session.threadId, "thread_code_agent_chat_smoke_stdio"); + assert.equal(stdioSecondTurn.sessionReuse.reused, true); + assert.equal(stdioSecondTurn.sessionReuse.turn, 2); + assert.ok(stdioSecondTurn.toolCalls.some((call) => call.name === "codex-reply" && call.status === "completed")); + assert.ok(stdioSecondTurn.toolCalls.some((call) => call.name === "ls" && call.status === "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"); + } finally { + await rm(stdioWorkspaceRoot, { recursive: true, force: true }); + } + const runnerSecondTurn = await handleCodeAgentChat( { conversationId: "cnv_code-agent-chat-runner-pwd", diff --git a/scripts/deploy-desired-state-plan.test.mjs b/scripts/deploy-desired-state-plan.test.mjs index 8a66916d..73c9cc21 100644 --- a/scripts/deploy-desired-state-plan.test.mjs +++ b/scripts/deploy-desired-state-plan.test.mjs @@ -52,7 +52,7 @@ async function makeFixture({ const providerEnv = serviceId === "hwlab-cloud-api" && deployCodeAgentProviderEnv ? { - HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-5.5", HWLAB_CODE_AGENT_OPENAI_BASE_URL: providerBaseUrl, OPENAI_API_KEY: "secretRef:hwlab-code-agent-provider/openai-api-key" @@ -61,7 +61,7 @@ async function makeFixture({ const workloadProviderEnv = serviceId === "hwlab-cloud-api" && workloadCodeAgentProviderEnv ? [ - { name: "HWLAB_CODE_AGENT_PROVIDER", value: "openai" }, + { name: "HWLAB_CODE_AGENT_PROVIDER", value: "codex-stdio" }, { name: "HWLAB_CODE_AGENT_MODEL", value: "gpt-5.5" }, { name: "HWLAB_CODE_AGENT_OPENAI_BASE_URL", value: providerBaseUrl }, { @@ -219,6 +219,9 @@ test("passes when cloud-api preserves provider env and DEV egress contract", asy assert.equal(plan.cloudApiDb.sslMode.deployMatches, true); assert.equal(plan.cloudApiDb.sslMode.workloadMatches, true); assert.equal(plan.codeAgentProvider.ready, true); + assert.equal(plan.codeAgentProvider.provider, "codex-stdio"); + assert.equal(plan.codeAgentProvider.runtimeProvider, "openai-responses"); + assert.equal(plan.codeAgentProvider.backend, "hwlab-cloud-api/codex-mcp-stdio"); assert.equal(plan.codeAgentProvider.secretRef.present, true); assert.equal(plan.codeAgentProvider.egress.deployMatchesDevProxy, true); assert.deepEqual(plan.diagnostics, []); diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 86cb19a9..98f18a91 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -342,8 +342,10 @@ function ensureCodeAgentRuntimeBase() { 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.HWLAB_CODE_AGENT_PROVIDER = process.env.HWLAB_CODE_AGENT_PROVIDER || "codex-stdio"; process.env.CODEX_HOME = codexHome; process.env.HWLAB_CODE_AGENT_CODEX_COMMAND = process.env.HWLAB_CODE_AGENT_CODEX_COMMAND || "/app/node_modules/.bin/codex"; + process.env.HWLAB_CODE_AGENT_SKILLS_DIRS = process.env.HWLAB_CODE_AGENT_SKILLS_DIRS || "/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills"; mkdirSync(path.dirname(workspace), { recursive: true }); if (!existsSync(workspace)) { @@ -821,6 +823,8 @@ function dockerfile(baseImage, port) { "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_CODE_AGENT_PROVIDER=codex-stdio", + "ENV HWLAB_CODE_AGENT_SKILLS_DIRS=/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills", "ENV HWLAB_ENVIRONMENT=$HWLAB_ENVIRONMENT", "ENV HWLAB_SERVICE_ID=$HWLAB_SERVICE_ID", "ENV HWLAB_ARTIFACT_KIND=$HWLAB_ARTIFACT_KIND", @@ -846,7 +850,7 @@ function dockerfile(baseImage, port) { "COPY skills ./skills", "COPY deploy ./deploy", "COPY reports ./reports", - "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 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 && (test -x /app/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex/codex || test -x /app/node_modules/@openai/codex-linux-arm64/vendor/aarch64-unknown-linux-musl/codex/codex)", `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/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index 56dcd4c1..d80c4a00 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -375,7 +375,7 @@ function validateCloudApiDbSource(ctx, env) { function validateCloudApiCodeAgentSource(ctx, env) { const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT; - expectEqual(ctx, env.HWLAB_CODE_AGENT_PROVIDER, contract.provider, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_PROVIDER", "cloud API Code Agent provider"); + expectEqual(ctx, env.HWLAB_CODE_AGENT_PROVIDER, contract.codeAgentProvider, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_PROVIDER", "cloud API Code Agent provider"); expectEqual(ctx, env.HWLAB_CODE_AGENT_MODEL, contract.model, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_MODEL", "cloud API Code Agent model"); expectEqual(ctx, env.HWLAB_CODE_AGENT_TIMEOUT_MS, "120000", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_TIMEOUT_MS", "cloud API Code Agent hard timeout budget"); expectEqual( @@ -551,7 +551,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])); - expectEqual(ctx, env.get("HWLAB_CODE_AGENT_PROVIDER")?.value, contract.provider, "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_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, "120000", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_TIMEOUT_MS", "cloud API workload Code Agent hard timeout budget"); expectEqual( diff --git a/scripts/src/deploy-desired-state-plan.mjs b/scripts/src/deploy-desired-state-plan.mjs index acae3004..bc35ec43 100644 --- a/scripts/src/deploy-desired-state-plan.mjs +++ b/scripts/src/deploy-desired-state-plan.mjs @@ -293,7 +293,7 @@ function inspectCodeAgentProviderDesiredState(ctx, deployService, workloadRecord path: "deploy.codeAgentProvider", message: "hwlab-cloud-api desired state must preserve Code Agent provider Secret ref and DEV egress proxy env", expected: { - provider: DEV_CODE_AGENT_PROVIDER_CONTRACT.provider, + provider: DEV_CODE_AGENT_PROVIDER_CONTRACT.codeAgentProvider, secretRef: `${DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].secretName}/${DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].secretKey}`, egressTarget: DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.target }, diff --git a/scripts/src/dev-deploy-apply.test.mjs b/scripts/src/dev-deploy-apply.test.mjs index 33834adb..706479f9 100644 --- a/scripts/src/dev-deploy-apply.test.mjs +++ b/scripts/src/dev-deploy-apply.test.mjs @@ -28,7 +28,7 @@ function codeAgentDeploy(env = {}) { { serviceId: "hwlab-cloud-api", env: { - HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-5.5", HWLAB_CODE_AGENT_OPENAI_BASE_URL: codeAgentBaseUrl, OPENAI_API_KEY: "secretRef:hwlab-code-agent-provider/openai-api-key", @@ -59,7 +59,7 @@ function codeAgentWorkloads(envOverrides = []) { { name: "hwlab-cloud-api", env: [ - { name: "HWLAB_CODE_AGENT_PROVIDER", value: "openai" }, + { name: "HWLAB_CODE_AGENT_PROVIDER", value: "codex-stdio" }, { name: "HWLAB_CODE_AGENT_MODEL", value: "gpt-5.5" }, { name: "HWLAB_CODE_AGENT_OPENAI_BASE_URL", value: codeAgentBaseUrl }, { @@ -305,6 +305,9 @@ test("Code Agent provider desired state preserves Secret ref and egress base URL assert.equal(inspection.ready, true); assert.equal(inspection.status, "pass"); + assert.equal(inspection.provider, "codex-stdio"); + assert.equal(inspection.runtimeProvider, "openai-responses"); + assert.equal(inspection.backend, "hwlab-cloud-api/codex-mcp-stdio"); assert.equal(inspection.secretRef.present, true); assert.equal(inspection.secretMaterialRead, false); assert.equal(inspection.egress.deployMatchesDevProxy, true); diff --git a/scripts/src/dev-gate-preflight.mjs b/scripts/src/dev-gate-preflight.mjs index d9223f75..095b0ae2 100644 --- a/scripts/src/dev-gate-preflight.mjs +++ b/scripts/src/dev-gate-preflight.mjs @@ -1320,10 +1320,10 @@ function inspectCodeAgentProviderStaticContract(deploy, workloads) { const deployBaseUrl = deployEnv[contract.egress.env]; const workloadBaseUrl = workloadEnv.get(contract.egress.env)?.value; const egressChecks = [ - ["deployEnv.HWLAB_CODE_AGENT_PROVIDER", deployEnv.HWLAB_CODE_AGENT_PROVIDER, contract.provider], + ["deployEnv.HWLAB_CODE_AGENT_PROVIDER", deployEnv.HWLAB_CODE_AGENT_PROVIDER, contract.codeAgentProvider], ["deployEnv.HWLAB_CODE_AGENT_MODEL", deployEnv.HWLAB_CODE_AGENT_MODEL, contract.model], ["deployEnv.HWLAB_CODE_AGENT_OPENAI_BASE_URL", deployBaseUrl, contract.egress.defaultBaseUrl], - ["workloadEnv.HWLAB_CODE_AGENT_PROVIDER", workloadEnv.get("HWLAB_CODE_AGENT_PROVIDER")?.value, contract.provider], + ["workloadEnv.HWLAB_CODE_AGENT_PROVIDER", workloadEnv.get("HWLAB_CODE_AGENT_PROVIDER")?.value, contract.codeAgentProvider], ["workloadEnv.HWLAB_CODE_AGENT_MODEL", workloadEnv.get("HWLAB_CODE_AGENT_MODEL")?.value, contract.model], ["workloadEnv.HWLAB_CODE_AGENT_OPENAI_BASE_URL", workloadBaseUrl, contract.egress.defaultBaseUrl] ]; @@ -1340,7 +1340,9 @@ function inspectCodeAgentProviderStaticContract(deploy, workloads) { return { contractVersion: contract.contractVersion, environment: contract.environment, - provider: contract.runtimeProvider, + provider: contract.codeAgentProvider, + runtimeProvider: contract.runtimeProvider, + backend: contract.backend, model: contract.model, ready: missingDeployEnv.length === 0 && diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index da061ef1..0407cbde 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -200,7 +200,7 @@ assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_SERVICE_NAMESPACE, undefined, "c assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_HOST, undefined, "cloud-api workload DB alias host is optional"); assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_PORT, undefined, "cloud-api workload DB alias port is optional"); assertCloudApiDbOptionalAlias(k8sServices); -assert.equal(cloudApi.env.HWLAB_CODE_AGENT_PROVIDER, "openai", "cloud-api Code Agent provider"); +assert.equal(cloudApi.env.HWLAB_CODE_AGENT_PROVIDER, DEV_CODE_AGENT_PROVIDER_CONTRACT.codeAgentProvider, "cloud-api Code Agent provider"); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_MODEL, DEV_CODE_AGENT_PROVIDER_CONTRACT.model, "cloud-api Code Agent model"); assert.equal( cloudApi.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL, @@ -265,7 +265,7 @@ function assertCodeAgentProviderWorkloadContract(env) { const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT; const secretRef = contract.secretRefs[0]; const apiKey = env[secretRef.env]?.valueFrom?.secretKeyRef; - assert.equal(env.HWLAB_CODE_AGENT_PROVIDER?.value, contract.provider, "cloud-api workload Code Agent provider"); + assert.equal(env.HWLAB_CODE_AGENT_PROVIDER?.value, contract.codeAgentProvider, "cloud-api workload Code Agent provider"); assert.equal(env.HWLAB_CODE_AGENT_MODEL?.value, contract.model, "cloud-api workload Code Agent model"); assert.equal( env.HWLAB_CODE_AGENT_OPENAI_BASE_URL?.value, diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 2118f15e..1a3ecdb1 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -3135,6 +3135,8 @@ function codeAgentBlockerCodes(availability) { function codeAgentBlockerChineseLabel(code) { return { codex_cli_binary_missing: "未找到受控 Codex CLI binary", + codex_cli_not_executable: "Codex CLI 无法执行 --version", + codex_cli_native_dependency_missing: "Codex CLI native 依赖缺失", runner_lifecycle_missing: "缺少 repo-owned lifecycle supervisor", stdio_protocol_not_wired: "Codex stdio 协议尚未接入", codex_stdio_supervisor_disabled: "Codex stdio supervisor 未启用", diff --git a/web/hwlab-cloud-web/code-agent-status.mjs b/web/hwlab-cloud-web/code-agent-status.mjs index 912ff97a..15c8db45 100644 --- a/web/hwlab-cloud-web/code-agent-status.mjs +++ b/web/hwlab-cloud-web/code-agent-status.mjs @@ -342,6 +342,8 @@ function readinessBlockers(payload) { ...array(payload?.longLivedSessionGate?.blockers).map((item) => item?.code ?? item), ...array(payload?.codexStdioFeasibility?.blockers).map((item) => item?.code ?? item), payload?.runtimeContract?.binary?.status === "missing" ? "codex_cli_binary_missing" : null, + payload?.runtimeContract?.binary?.present === true && payload?.runtimeContract?.binary?.executable === false ? "codex_cli_not_executable" : null, + payload?.runtimeContract?.binary?.present === true && payload?.runtimeContract?.binary?.nativeDependencyPresent === false ? "codex_cli_native_dependency_missing" : null, payload?.runtimeContract?.lifecycleSupervisor?.status === "blocked" ? "runner_lifecycle_missing" : null, payload?.runtimeContract?.stdioProtocol?.status === "blocked" ? "stdio_protocol_not_wired" : null, payload?.reason,