diff --git a/deploy/deploy.json b/deploy/deploy.json index 5bb4f322..1e92f656 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -205,6 +205,10 @@ "HWLAB_CODE_AGENT_PROVIDER": "openai", "HWLAB_CODE_AGENT_MODEL": "gpt-5.5", "HWLAB_CODE_AGENT_OPENAI_BASE_URL": "http://172.26.26.227:17680/v1/responses", + "HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED": "1", + "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR": "repo-owned", + "HWLAB_CODE_AGENT_CODEX_WORKSPACE": "/workspace/hwlab", + "HWLAB_CODE_AGENT_CODEX_SANDBOX": "workspace-write", "OPENAI_API_KEY": "secretRef:hwlab-code-agent-provider/openai-api-key" } }, diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index dad77c95..92afa260 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -418,6 +418,22 @@ "type": "string", "const": "http://172.26.26.227:17680/v1/responses" }, + "HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED": { + "type": "string", + "const": "1" + }, + "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR": { + "type": "string", + "const": "repo-owned" + }, + "HWLAB_CODE_AGENT_CODEX_WORKSPACE": { + "type": "string", + "const": "/workspace/hwlab" + }, + "HWLAB_CODE_AGENT_CODEX_SANDBOX": { + "type": "string", + "const": "workspace-write" + }, "OPENAI_API_KEY": { "type": "string", "const": "secretRef:hwlab-code-agent-provider/openai-api-key" diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 0d91cd72..8cd96602 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -117,6 +117,22 @@ "name": "HWLAB_CODE_AGENT_OPENAI_BASE_URL", "value": "http://172.26.26.227:17680/v1/responses" }, + { + "name": "HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED", + "value": "1" + }, + { + "name": "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", + "value": "repo-owned" + }, + { + "name": "HWLAB_CODE_AGENT_CODEX_WORKSPACE", + "value": "/workspace/hwlab" + }, + { + "name": "HWLAB_CODE_AGENT_CODEX_SANDBOX", + "value": "workspace-write" + }, { "name": "OPENAI_API_KEY", "valueFrom": { diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index afdf7b61..22bc09cb 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -11,6 +11,15 @@ import { codeAgentSecretRefPlaceholder, inspectCodeAgentProviderEnv } from "./code-agent-contract.mjs"; +import { + CODEX_STDIO_BACKEND, + CODEX_STDIO_IMPLEMENTATION_TYPE, + CODEX_STDIO_PROVIDER, + CODEX_STDIO_RUNNER_KIND, + CODEX_STDIO_SESSION_MODE, + createCodexStdioSessionManager, + longLivedSessionGate as codexLongLivedSessionGate +} from "./codex-stdio-session.mjs"; import { DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS, createCodeAgentSessionRegistry @@ -58,6 +67,7 @@ const READONLY_FILE_TREE_DEPTH = 8; const MAX_READONLY_SESSIONS = 200; const MAX_SKILLS_RETURNED = 40; const CODE_AGENT_PROVIDER_SECRET_REF = codeAgentSecretRefPlaceholder().replace("secretRef:", ""); +const CODEX_STDIO_SOURCE_ISSUE = "pikasTech/HWLAB#275"; const READONLY_LIMITATION_FLAGS = Object.freeze([ "not-codex-stdio", "not-write-capable", @@ -88,6 +98,7 @@ const defaultCodeAgentSessionRegistry = createCodeAgentSessionRegistry({ idleTimeoutMs: DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS, maxSessions: MAX_READONLY_SESSIONS }); +const defaultCodexStdioSessionManager = createCodexStdioSessionManager(); export async function handleCodeAgentChat(params = {}, options = {}) { const timestamp = nowIso(options.now); @@ -124,39 +135,44 @@ export async function handleCodeAgentChat(params = {}, options = {}) { sessionRegistry: options.sessionRegistry, requestJson: options.m3IoSkillRequestJson }); - const completedAt = nowIso(options.now); - return { - ...base, - sessionId: runnerResult.session?.sessionId ?? base.sessionId, - status: "completed", - updatedAt: completedAt, - provider: runnerResult.provider, - model: runnerResult.model, - backend: runnerResult.backend, - workspace: runnerResult.workspace, - sandbox: runnerResult.sandbox, - session: runnerResult.session, - sessionMode: runnerResult.sessionMode, - sessionReuse: runnerResult.sessionReuse, - implementationType: runnerResult.implementationType, - runnerLimitations: runnerResult.runnerLimitations, - codexStdioFeasibility: runnerResult.codexStdioFeasibility, - longLivedSessionGate: runnerResult.longLivedSessionGate, - toolCalls: runnerResult.toolCalls, - skills: runnerResult.skills, - runner: runnerResult.runner, - runnerTrace: runnerResult.runnerTrace, - capabilityLevel: runnerResult.capabilityLevel, - reply: { - messageId, - role: "assistant", - content: runnerResult.content, - createdAt: completedAt - }, - usage: null, - providerTrace: runnerResult.providerTrace - }; + return completedRunnerPayload({ base, runnerResult, messageId, now: options.now }); } + + const securityIntent = runnerIntent.kind === "security" ? runnerIntent : null; + if (securityIntent) { + const runnerResult = await callReadOnlyRunner({ + intent: securityIntent, + conversationId, + traceId, + sessionId: requestedSessionId, + env: options.env ?? process.env, + now: options.now, + workspace: options.workspace, + skillsDirs: options.skillsDirs, + skillsDirsExact: options.skillsDirsExact, + sessionRegistry: options.sessionRegistry, + codexStdioManager: options.codexStdioManager + }); + 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 stdioResult = await callCodexStdioRunner({ + message, + conversationId, + sessionId: requestedSessionId, + traceId, + env: options.env ?? process.env, + now: options.now, + workspace: options.workspace, + timeoutMs: options.timeoutMs, + model: providerPlan.model, + codexStdioManager: options.codexStdioManager + }); + return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now }); + } + if (runnerIntent.kind !== "none") { const runnerResult = await callReadOnlyRunner({ intent: runnerIntent, @@ -168,40 +184,10 @@ export async function handleCodeAgentChat(params = {}, options = {}) { workspace: options.workspace, skillsDirs: options.skillsDirs, skillsDirsExact: options.skillsDirsExact, - sessionRegistry: options.sessionRegistry + sessionRegistry: options.sessionRegistry, + codexStdioManager: options.codexStdioManager }); - const completedAt = nowIso(options.now); - return { - ...base, - sessionId: runnerResult.session?.sessionId ?? base.sessionId, - status: "completed", - updatedAt: completedAt, - provider: runnerResult.provider, - model: runnerResult.model, - backend: runnerResult.backend, - workspace: runnerResult.workspace, - sandbox: runnerResult.sandbox, - session: runnerResult.session, - sessionMode: runnerResult.sessionMode, - sessionReuse: runnerResult.sessionReuse, - implementationType: runnerResult.implementationType, - runnerLimitations: runnerResult.runnerLimitations, - codexStdioFeasibility: runnerResult.codexStdioFeasibility, - longLivedSessionGate: runnerResult.longLivedSessionGate, - toolCalls: runnerResult.toolCalls, - skills: runnerResult.skills, - runner: runnerResult.runner, - runnerTrace: runnerResult.runnerTrace, - capabilityLevel: runnerResult.capabilityLevel, - reply: { - messageId, - role: "assistant", - content: runnerResult.content, - createdAt: completedAt - }, - usage: null, - providerTrace: runnerResult.providerTrace - }; + return completedRunnerPayload({ base, runnerResult, messageId, now: options.now }); } const providerResult = await callConfiguredProvider({ @@ -242,14 +228,14 @@ export async function handleCodeAgentChat(params = {}, options = {}) { "not-workspace-tools", "not-durable-session" ], - codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(envForFeasibility(options.env ?? process.env)), + codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(options.env ?? process.env, options), longLivedSessionGate: providerResult.longLivedSessionGate ?? longLivedSessionGate({ provider: providerResult.provider ?? base.provider, runnerKind: providerResult.runner?.kind ?? OPENAI_FALLBACK_RUNNER_KIND, session: providerResult.session ?? null, sessionMode: providerResult.sessionMode ?? "provider-text-request", implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND, - codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(envForFeasibility(options.env ?? process.env)) + codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(options.env ?? process.env, options) }), toolCalls: Array.isArray(providerResult.toolCalls) ? providerResult.toolCalls : [], skills: providerResult.skills ?? { @@ -299,15 +285,52 @@ export async function handleCodeAgentChat(params = {}, options = {}) { if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations; if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility; if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate; - if (error.code === "provider_unavailable") { + if (error.availability !== undefined) { + payload.availability = error.availability; + } else if (error.code === "provider_unavailable") { payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options); - } else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked"].includes(error.code)) { + } 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); } return payload; } } +function completedRunnerPayload({ base, runnerResult, messageId, now }) { + const completedAt = nowIso(now); + return { + ...base, + sessionId: runnerResult.session?.sessionId ?? base.sessionId, + status: "completed", + updatedAt: completedAt, + provider: runnerResult.provider, + model: runnerResult.model, + backend: runnerResult.backend, + workspace: runnerResult.workspace, + sandbox: runnerResult.sandbox, + session: runnerResult.session, + sessionMode: runnerResult.sessionMode, + sessionReuse: runnerResult.sessionReuse, + implementationType: runnerResult.implementationType, + runnerLimitations: runnerResult.runnerLimitations, + codexStdioFeasibility: runnerResult.codexStdioFeasibility, + longLivedSessionGate: runnerResult.longLivedSessionGate, + toolCalls: runnerResult.toolCalls, + skills: runnerResult.skills, + runner: runnerResult.runner, + runnerTrace: runnerResult.runnerTrace, + capabilityLevel: runnerResult.capabilityLevel, + reply: { + messageId, + role: "assistant", + content: runnerResult.content, + createdAt: completedAt + }, + usage: null, + providerTrace: runnerResult.providerTrace + }; +} + export function validateCodeAgentChatSchema(payload) { const required = [ "conversationId", @@ -347,18 +370,65 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) { ? providerContract.missingEnv : []; - if (providerPlan.mode !== "openai" && !env.OPENAI_API_KEY) { + if (providerPlan.mode === "codex-cli" && !env.OPENAI_API_KEY) { missingEnv.push("OPENAI_API_KEY"); } - - const blocked = providerPlan.mode === "openai" - ? !providerContract.ready - : missingEnv.length > 0; const sessionRegistry = resolveCodeAgentSessionRegistry(options); const runnerAvailability = inspectReadOnlyRunnerAvailability(env, { ...options, sessionRegistry }); + const codexStdio = inspectCodexStdioFeasibility(env, options); + const blocked = providerPlan.mode === "openai" + ? !providerContract.ready + : providerPlan.mode === "codex-stdio" + ? !codexStdio.ready + : missingEnv.length > 0; + const fallbackAvailability = { + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + mode: providerPlan.mode, + status: blocked ? "blocked" : "available", + ready: !blocked, + capabilityLevel: blocked ? "blocked" : "text-chat-only", + blocker: blocked ? fallbackBlockerForPlan({ providerPlan, providerContract, codexStdio }) : null, + reason: blocked ? "provider_unavailable" : null, + missingEnv, + secretRefs: blocked ? providerContract.secretRefs : [], + egress: providerContract.egress, + safety: providerContract.safety + }; + const longLivedGate = longLivedSessionGate({ + provider: codexStdio.ready ? CODEX_STDIO_PROVIDER : runnerAvailability.provider, + runnerKind: codexStdio.ready ? CODEX_STDIO_RUNNER_KIND : runnerAvailability.kind, + sessionMode: codexStdio.ready ? CODEX_STDIO_SESSION_MODE : READONLY_SESSION_MODE, + implementationType: codexStdio.ready ? CODEX_STDIO_IMPLEMENTATION_TYPE : READONLY_IMPLEMENTATION_TYPE, + codexStdioFeasibility: codexStdio + }); + const agentKind = codexStdio.ready + ? "codex-stdio-feasible" + : runnerAvailability.ready + ? "controlled-readonly-session-registry" + : blocked + ? "openai-fallback-blocked" + : "openai-fallback"; + const capabilityStatus = codexStdio.ready + ? "codex-stdio-feasible" + : runnerAvailability.ready + ? "controlled-readonly-session-registry" + : blocked + ? "blocked" + : "openai-fallback"; + const codeAgentReady = codexStdio.ready; + const status = codexStdio.ready ? "codex-stdio-feasible" : blocked && !runnerAvailability.ready ? "blocked" : "partial"; + const capabilityLevel = codexStdio.ready + ? "long-lived-codex-stdio-session" + : runnerAvailability.ready + ? READONLY_SESSION_CAPABILITY_LEVEL + : blocked + ? "blocked" + : "text-chat-only"; return { endpoint: "POST /v1/agent/chat", provider: providerPlan.provider, @@ -396,27 +466,79 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) { "availability.status" ], runner: runnerAvailability, - status: blocked && !runnerAvailability.ready ? "blocked" : "available", - blocker: blocked && !runnerAvailability.ready ? (providerPlan.mode === "openai" ? providerContract.blocker : "凭证缺口") : null, - reason: blocked && !runnerAvailability.ready ? "provider_unavailable" : null, - summary: blocked - ? `受控只读 runner 可用于 pwd/skills 等工作区能力,M3 IO 只允许 Skill CLI 调用 ${HWLAB_M3_IO_API_ROUTE};OpenAI Responses fallback 当前受 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 或 DEV egress/base-url contract 影响。` - : `真实后端已接入;pwd/skills 走受控只读 runner,M3 IO 走 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE},普通聊天可走 OpenAI Responses fallback。OpenAI fallback 不满足 Codex runner capability gate。`, + fallback: fallbackAvailability, + codexStdio, + status, + readinessStatus: status, + agentKind, + capabilityStatus, + blocker: codeAgentReady ? null : primaryCodeAgentBlocker({ blocked, providerContract, providerPlan, codexStdio, runnerAvailability }), + reason: codeAgentReady ? null : primaryCodeAgentReason({ blocked, codexStdio, runnerAvailability }), + summary: codeAgentAvailabilitySummary({ blocked, codexStdio, runnerAvailability }), missingEnv, secretRefs: blocked ? providerContract.secretRefs : [], egress: providerContract.egress, safety: providerContract.safety, - capabilityLevel: runnerAvailability.ready ? READONLY_SESSION_CAPABILITY_LEVEL : blocked ? "blocked" : "text-chat-only", + capabilityLevel, sessionRegistry: runnerAvailability.sessionRegistry, sessionMode: runnerAvailability.sessionMode, implementationType: runnerAvailability.implementationType, runnerLimitations: runnerAvailability.runnerLimitations, - codexStdioFeasibility: runnerAvailability.codexStdioFeasibility, - longLivedSessionGate: runnerAvailability.longLivedSessionGate, - ready: !blocked || runnerAvailability.ready + codexStdioFeasibility: codexStdio, + longLivedSessionGate: longLivedGate, + blockers: [ + ...codexStdio.blockers, + ...(blocked && providerPlan.mode !== "codex-stdio" ? [{ + code: "openai_fallback_blocked", + sourceIssue: "pikasTech/HWLAB#143", + summary: providerPlan.mode === "openai" ? providerContract.blocker : "Provider fallback credential/config is incomplete." + }] : []) + ], + blockerCodes: [ + ...codexStdio.blockerCodes, + ...(blocked && providerPlan.mode !== "codex-stdio" ? ["openai_fallback_blocked"] : []) + ], + ready: codeAgentReady, + partialReady: runnerAvailability.ready || !blocked }; } +function fallbackBlockerForPlan({ providerPlan, providerContract, codexStdio }) { + if (providerPlan.mode === "openai") return providerContract.blocker; + if (providerPlan.mode === "codex-stdio") { + return codexStdio.blockers?.[0]?.summary ?? "Codex stdio long-lived session is blocked."; + } + return "凭证缺口"; +} + +function primaryCodeAgentBlocker({ blocked, providerContract, providerPlan, codexStdio, runnerAvailability }) { + if (codexStdio.blockers?.length > 0) return codexStdio.blockers[0].summary; + if (blocked && !runnerAvailability.ready) return providerPlan.mode === "openai" ? providerContract.blocker : "凭证缺口"; + if (runnerAvailability.ready) return "controlled-readonly-session-registry is available, but long-lived Codex stdio is blocked."; + return null; +} + +function primaryCodeAgentReason({ blocked, codexStdio, runnerAvailability }) { + if (codexStdio.blockers?.length > 0) return codexStdio.blockers[0].code; + if (blocked && !runnerAvailability.ready) return "provider_unavailable"; + if (runnerAvailability.ready) return "controlled_readonly_not_long_lived_stdio"; + return null; +} + +function codeAgentAvailabilitySummary({ blocked, codexStdio, runnerAvailability }) { + if (codexStdio.ready) { + return "Codex stdio long-lived session adapter is feasible; /v1/agent/chat can create/reuse/cancel/reap sessions with trace capture. Hardware control still must use cloud-api/HWLAB API/skill CLI."; + } + if (runnerAvailability.ready) { + return blocked + ? `受控只读 runner 可用于 pwd/skills/ls/rg/cat,M3 IO 只允许 Skill CLI 调用 ${HWLAB_M3_IO_API_ROUTE};OpenAI fallback 受 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 或 egress/base-url contract 影响;long-lived Codex stdio 仍 blocked。` + : `普通聊天可走 OpenAI Responses fallback,pwd/skills 走受控只读 runner,M3 IO 走 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE};这些能力都不能冒充完整 long-lived Codex stdio Code Agent。`; + } + return blocked + ? "OpenAI fallback 和 Codex stdio long-lived session 都 blocked;不返回伪完成。" + : "Only text-chat fallback is available; it does not satisfy the Codex stdio session gate."; +} + function resolveProviderPlan(env, options = {}) { const provider = String(env.HWLAB_CODE_AGENT_PROVIDER || "auto").trim().toLowerCase(); const model = firstNonEmpty( @@ -443,6 +565,14 @@ function resolveProviderPlan(env, options = {}) { mode: "codex-cli" }; } + if (provider === "codex-stdio" || provider === "codex-mcp-stdio") { + return { + provider: CODEX_STDIO_PROVIDER, + model, + backend: CODEX_STDIO_BACKEND, + mode: "codex-stdio" + }; + } return { provider: env.OPENAI_API_KEY ? "openai-responses" : "codex-cli", model, @@ -476,7 +606,7 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) { const workspaceReady = Boolean(workspace && existsSync(workspace)); const skillsDirs = resolveSkillDirs(env, options); const skillsDirsPresent = skillsDirs.filter((dir) => existsSync(dir)); - const codexStdioFeasibility = inspectCodexStdioFeasibility(envForFeasibility(env)); + const codexStdioFeasibility = inspectCodexStdioFeasibility(env, options); const sessionRegistry = resolveCodeAgentSessionRegistry(options).describe(); return { kind: READONLY_RUNNER_KIND, @@ -522,7 +652,80 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) { }; } -async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact, sessionRegistry }) { +async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager }) { + const manager = resolveCodexStdioSessionManager({ codexStdioManager }); + try { + return await manager.chat({ + message, + conversationId, + sessionId, + traceId, + env, + now, + workspace, + timeoutMs, + model + }); + } catch (error) { + const availability = error.availability ?? manager.describe({ env, workspace }); + const session = error.session ?? null; + const trace = error.runnerTrace ?? { + traceId, + runnerKind: CODEX_STDIO_RUNNER_KIND, + workspace: workspace ?? availability.workspace ?? repoRoot, + sandbox: availability.sandbox ?? "workspace-write", + sessionMode: CODEX_STDIO_SESSION_MODE, + events: [`blocked:${error.code ?? "codex_stdio_failed"}`], + valuesPrinted: false + }; + throw runnerError(error.code ?? "codex_stdio_failed", error.message, { + provider: CODEX_STDIO_PROVIDER, + model, + backend: CODEX_STDIO_BACKEND, + workspace: workspace ?? availability.workspace ?? null, + sandbox: availability.sandbox ?? "workspace-write", + session, + toolCalls: [], + skills: notRequestedSkills(), + runner: { + kind: CODEX_STDIO_RUNNER_KIND, + provider: CODEX_STDIO_PROVIDER, + backend: CODEX_STDIO_BACKEND, + workspace: workspace ?? availability.workspace ?? repoRoot, + sandbox: availability.sandbox ?? "workspace-write", + session: CODEX_STDIO_SESSION_MODE, + sessionMode: CODEX_STDIO_SESSION_MODE, + sessionId: session?.sessionId ?? null, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + codexStdio: true, + longLivedSession: true, + durableSession: true, + writeCapable: true, + readOnly: false, + capabilityLevel: "blocked" + }, + runnerTrace: trace, + capabilityLevel: "blocked", + sessionMode: CODEX_STDIO_SESSION_MODE, + sessionReuse: session ? sessionReuseEvidence(session) : null, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + runnerLimitations: ["codex-stdio-blocked"], + codexStdioFeasibility: availability, + longLivedSessionGate: longLivedSessionGate({ + provider: CODEX_STDIO_PROVIDER, + runnerKind: CODEX_STDIO_RUNNER_KIND, + session, + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + codexStdioFeasibility: availability + }), + blockers: error.blockers ?? availability.blockers, + availability: describeCodeAgentAvailability(env, { codexStdioManager: manager, workspace }) + }); + } +} + +async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact, sessionRegistry, codexStdioManager }) { const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace }); const registry = resolveCodeAgentSessionRegistry({ sessionRegistry }); const sessionAcquire = registry.acquire({ @@ -558,14 +761,14 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, sessionReuse: sessionReuseEvidence(blockedSession), implementationType: READONLY_IMPLEMENTATION_TYPE, runnerLimitations: [...READONLY_LIMITATION_FLAGS], - codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env)), + codexStdioFeasibility: inspectCodexStdioFeasibility(env, { codexStdioManager, workspace }), longLivedSessionGate: longLivedSessionGate({ provider: READONLY_RUNNER_PROVIDER, runnerKind: READONLY_RUNNER_KIND, session: blockedSession, sessionMode: READONLY_SESSION_MODE, implementationType: READONLY_IMPLEMENTATION_TYPE, - codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env)) + codexStdioFeasibility: inspectCodexStdioFeasibility(env, { codexStdioManager, workspace }) }), blockers: [sessionAcquire.blocker] }); @@ -573,7 +776,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(envForFeasibility(env)); + const codexStdioFeasibility = inspectCodexStdioFeasibility(env, { codexStdioManager, workspace: resolvedWorkspace }); const events = [ `intent:${intent.kind}`, "sandbox:read-only", @@ -864,7 +1067,7 @@ async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, now }); const startedAt = nowIso(now); - const codexStdioFeasibility = inspectCodexStdioFeasibility(envForFeasibility(env)); + const codexStdioFeasibility = inspectCodexStdioFeasibility(env, { workspace: resolvedWorkspace }); if (!sessionAcquire.ok) { const blockedSession = sessionAcquire.session; throw runnerError(sessionAcquire.code, sessionAcquire.message, { @@ -1845,6 +2048,16 @@ function resolveCodeAgentSessionRegistry(options = {}) { : defaultCodeAgentSessionRegistry; } +function resolveCodexStdioSessionManager(options = {}) { + return options.codexStdioManager && + typeof options.codexStdioManager.describe === "function" && + typeof options.codexStdioManager.chat === "function" && + typeof options.codexStdioManager.cancel === "function" && + typeof options.codexStdioManager.reapIdle === "function" + ? options.codexStdioManager + : defaultCodexStdioSessionManager; +} + function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = null } = {}) { const allowed = kind === READONLY_RUNNER_KIND ? ["pwd", "skills.discover", "ls", "rg --files", "cat", `m3.io.skill-cli:${HWLAB_M3_IO_API_ROUTE}`] @@ -1918,59 +2131,20 @@ function runnerSafetyContract() { }; } -function inspectCodexStdioFeasibility(env = process.env) { - const workspace = resolveRunnerWorkspace(env); - const command = firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_COMMAND, DEFAULT_CODEX_COMMAND); - const binaryOnPath = commandOnPathSync(command, env); - const blockers = []; - if (!binaryOnPath) { - blockers.push({ - code: "codex_cli_binary_missing", - sourceIssue: "pikasTech/HWLAB#275", - summary: `Codex CLI command ${command} is not present on the DEV cloud-api runtime PATH.` - }); - } - if (!existsSync(workspace)) { - blockers.push({ - code: "workspace_mount_missing", - sourceIssue: "pikasTech/HWLAB#275", - summary: `Configured runner workspace is not mounted/readable: ${workspace}.` - }); - } - if (!env.OPENAI_API_KEY) { - blockers.push({ - code: "provider_token_boundary", - sourceIssue: "pikasTech/HWLAB#275", - summary: "A long-lived Codex runner would need a repo-owned token/secret injection boundary; this endpoint does not read or print provider secrets." - }); - } - blockers.push({ - code: "runner_lifecycle_missing", - sourceIssue: "pikasTech/HWLAB#275", - summary: "cloud-api currently owns request/response HTTP handling only; no repo-owned supervisor exists to spawn, monitor, reap, and isolate long-lived Codex stdio sessions." - }); - blockers.push({ - code: "stdio_protocol_not_wired", - sourceIssue: "pikasTech/HWLAB#275", - summary: "No Codex stdio protocol adapter is implemented for multi-turn session IO, cancellation, trace capture, and bounded tool evidence." +function inspectCodexStdioFeasibility(env = process.env, options = {}) { + const manager = resolveCodexStdioSessionManager(options); + const feasibility = manager.describe({ + env, + workspace: options.workspace, + command: options.command, + sandbox: options.sandbox }); return { - status: blockers.length === 0 ? "ready" : "blocked", - canStartLongLivedCodexStdio: blockers.length === 0, + ...feasibility, checked: true, implementationRequired: "codex-stdio-or-equivalent-long-lived-runner", - currentImplementation: READONLY_IMPLEMENTATION_TYPE, - command, - binaryOnPath, - workspace, - sandbox: READONLY_RUNNER_SANDBOX, - blockers, - safety: { - secretsRead: false, - secretValuesPrinted: false, - prodTouched: false, - hardwareWritesAllowed: false - } + currentImplementation: feasibility.ready ? CODEX_STDIO_IMPLEMENTATION_TYPE : READONLY_IMPLEMENTATION_TYPE, + sourceIssue: CODEX_STDIO_SOURCE_ISSUE }; } @@ -1986,87 +2160,14 @@ function longLivedSessionGate({ const normalizedRunnerKind = String(runnerKind ?? "").trim(); const normalizedSessionMode = String(sessionMode ?? "").trim(); const normalizedImplementation = String(implementationType ?? "").trim(); - const blockers = []; - if (normalizedProvider === "openai-responses" || normalizedRunnerKind === OPENAI_FALLBACK_RUNNER_KIND) { - blockers.push({ - code: "openai_responses_fallback_not_session", - sourceIssue: "pikasTech/HWLAB#317", - summary: "OpenAI Responses fallback is text-chat-only and cannot pass the long-lived Codex session gate." - }); - } - if (normalizedRunnerKind === CODEX_CLI_ONE_SHOT_RUNNER_KIND || normalizedSessionMode === "ephemeral-one-shot") { - blockers.push({ - code: "one_shot_runner_not_long_lived", - sourceIssue: "pikasTech/HWLAB#317", - summary: "codex exec --ephemeral / one-shot runner output is not a reusable long-lived runner session." - }); - } - if (normalizedImplementation === READONLY_IMPLEMENTATION_TYPE) { - blockers.push({ - code: "stdio_protocol_not_wired", - sourceIssue: "pikasTech/HWLAB#317", - summary: "This response is backed by the controlled read-only session registry, not Codex stdio or an equivalent long-lived protocol adapter." - }); - } - for (const blocker of codexStdioFeasibility?.blockers ?? []) { - if (!blocker?.code || blockers.some((item) => item.code === blocker.code)) continue; - blockers.push({ - code: blocker.code, - sourceIssue: blocker.sourceIssue ?? "pikasTech/HWLAB#317", - summary: blocker.summary ?? `Codex stdio feasibility blocker: ${blocker.code}.` - }); - } - - const pass = - normalizedProvider === READONLY_RUNNER_PROVIDER && - normalizedRunnerKind !== OPENAI_FALLBACK_RUNNER_KIND && - normalizedRunnerKind !== CODEX_CLI_ONE_SHOT_RUNNER_KIND && - session?.longLivedSession === true && - session?.codexStdio === true && - codexStdioFeasibility?.canStartLongLivedCodexStdio === true && - blockers.length === 0; - - return { - status: pass ? "pass" : "blocked", - pass, - requiredCapability: "long-lived-codex-stdio-session", - currentCapability: normalizedImplementation || normalizedSessionMode || normalizedRunnerKind || "unknown", - sessionId: session?.sessionId ?? null, - sessionStatus: session?.status ?? null, - runnerKind: normalizedRunnerKind || null, - provider: normalizedProvider || null, - sessionMode: normalizedSessionMode || null, - implementationType: normalizedImplementation || null, - blockers, - sourceIssue: "pikasTech/HWLAB#317", - summary: pass - ? "Long-lived Codex stdio/session gate passed." - : "Long-lived Codex stdio/session gate remains blocked; current response must not be reported as full #317 completion." - }; -} - -function envForFeasibility(env = process.env) { - return { - PATH: env.PATH || process.env.PATH || "/usr/local/bin:/usr/bin:/bin", - HWLAB_CODE_AGENT_CODEX_COMMAND: env.HWLAB_CODE_AGENT_CODEX_COMMAND, - HWLAB_CODE_AGENT_WORKSPACE: env.HWLAB_CODE_AGENT_WORKSPACE, - HWLAB_RUNNER_WORKSPACE: env.HWLAB_RUNNER_WORKSPACE, - WORKSPACE: env.WORKSPACE, - OPENAI_API_KEY: env.OPENAI_API_KEY ? "__present__" : "" - }; -} - -function commandOnPathSync(command, env = process.env) { - if (!command) return false; - if (command.includes("/") || command.includes("\\")) { - try { - return existsSync(command); - } catch { - return false; - } - } - const paths = String(env.PATH || process.env.PATH || "").split(path.delimiter).filter(Boolean); - return paths.some((dir) => existsSync(path.join(dir, command))); + return codexLongLivedSessionGate({ + provider: normalizedProvider, + runnerKind: normalizedRunnerKind, + session, + sessionMode: normalizedSessionMode, + implementationType: normalizedImplementation, + codexStdioFeasibility + }); } function openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, sessionId }) { @@ -2120,7 +2221,7 @@ function runnerError(code, message, details = {}) { function runnerCommandEnv(env = process.env) { return { - PATH: env.PATH || process.env.PATH || "/usr/local/bin:/usr/bin:/bin", + PATH: Object.hasOwn(env, "PATH") ? env.PATH : process.env.PATH || "/usr/local/bin:/usr/bin:/bin", HOME: env.HOME || process.env.HOME || os.homedir() }; } @@ -2336,13 +2437,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(envForFeasibility(env)), + codexStdioFeasibility: inspectCodexStdioFeasibility(env), longLivedSessionGate: longLivedSessionGate({ provider: providerPlan.provider, runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND, sessionMode: "ephemeral-one-shot", implementationType: "codex-cli-one-shot-ephemeral", - codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env)) + codexStdioFeasibility: inspectCodexStdioFeasibility(env) }), toolCalls: [], skills: notRequestedSkills(), @@ -2409,7 +2510,8 @@ function normalizeChatError(error) { "exitCode", "providerStatus", "stderrSummary", - "blockers" + "blockers", + "missingTools" ]) { if (error[key] !== undefined) { normalized[key] = error[key]; diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 9aa2e4f0..5fdd9321 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs"; import { handleCodeAgentChat, validateCodeAgentChatSchema } from "./code-agent-chat.mjs"; +import { createCodexStdioSessionManager } from "./codex-stdio-session.mjs"; import { classifyCodexRunnerCapability, classifyCodeAgentChatReadiness @@ -132,7 +133,7 @@ test("read-only runner returns session registry evidence and blocked long-lived assert.equal(first.capabilityLevel, "read-only-session-tools"); assert.equal(first.longLivedSessionGate.status, "blocked"); assert.equal(first.longLivedSessionGate.pass, false); - assert.ok(first.longLivedSessionGate.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired")); + assert.ok(first.longLivedSessionGate.blockers.some((blocker) => blocker.code === "controlled_readonly_not_long_lived_stdio")); const second = await handleCodeAgentChat( { @@ -154,7 +155,7 @@ test("read-only runner returns session registry evidence and blocked long-lived assert.equal(second.sessionReuse.reused, true); assert.equal(second.sessionReuse.turn, 2); assert.equal(second.session.lastTraceId, "trc_chat_registry_ls"); - assert.equal(classifyCodexRunnerCapability(second, { httpStatus: 200 }).capabilityPass, true); + assert.equal(classifyCodexRunnerCapability(second, { httpStatus: 200 }).capabilityPass, false); }); test("expired and busy session states are surfaced as structured blockers", async () => { @@ -501,3 +502,123 @@ test("Code Agent blocks direct gateway or patch-panel requests instead of using assert.equal(direct.toolCalls[0].name, "security.hardware-boundary"); assert.match(direct.error.message, /Skill CLI -> HWLAB API \/v1\/m3\/io/u); }); + +test("Codex stdio manager reports concrete blockers without falling back to readonly", async () => { + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_stdio_blocked" + }); + const blocked = await handleCodeAgentChat( + { + conversationId: "cnv_stdio_blocked", + traceId: "trc_stdio_blocked", + message: "请用同一个 Code Agent session 继续" + }, + { + now: () => "2026-05-23T00:05:00.000Z", + codexStdioManager: manager, + env: { + PATH: "", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex", + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + } + } + ); + assert.equal(blocked.status, "failed"); + assert.equal(blocked.provider, "codex-stdio"); + assert.equal(blocked.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(blocked.error.code, "codex_stdio_blocked"); + assert.ok(blocked.error.blockers.some((blocker) => blocker.code === "codex_cli_binary_missing")); + assert.ok(blocked.error.blockers.some((blocker) => blocker.code === "provider_token_boundary")); + assert.equal(blocked.capabilityLevel, "blocked"); + assert.equal(blocked.longLivedSessionGate.status, "blocked"); + assert.equal(blocked.availability.ready, false); + assert.equal(blocked.availability.codexStdio.status, "blocked"); + assert.equal(Object.hasOwn(blocked, "reply"), false); +}); + +test("repo-owned Codex stdio manager creates and reuses long-lived sessions with trace evidence", async () => { + const calls = []; + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_stdio_ready", + createRpcClient: async () => ({ + async initialize() { + return { tools: ["codex", "codex-reply"] }; + }, + async listTools() { + return ["codex", "codex-reply"]; + }, + async callTool(name, args) { + calls.push({ name, args }); + return { + structuredContent: { + threadId: "thread_stdio_ready", + content: name === "codex" ? "first stdio reply" : "second stdio reply" + } + }; + }, + close() {} + }) + }); + const env = { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + 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_WORKSPACE: process.cwd() + }; + + const first = await handleCodeAgentChat( + { + conversationId: "cnv_stdio_ready", + traceId: "trc_stdio_ready_1", + message: "first" + }, + { + now: () => "2026-05-23T00:06:00.000Z", + codexStdioManager: manager, + env + } + ); + validateCodeAgentChatSchema(first); + assert.equal(first.status, "completed"); + assert.equal(first.provider, "codex-stdio"); + assert.equal(first.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(first.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(first.implementationType, "repo-owned-codex-mcp-stdio-session"); + assert.equal(first.session.longLivedSession, true); + assert.equal(first.session.codexStdio, true); + assert.equal(first.runner.writeCapable, true); + assert.equal(first.runner.durableSession, true); + assert.equal(first.capabilityLevel, "long-lived-codex-stdio-session"); + assert.equal(first.longLivedSessionGate.status, "pass"); + assert.equal(first.longLivedSessionGate.pass, true); + assert.equal(classifyCodexRunnerCapability(first, { httpStatus: 200 }).capabilityPass, true); + + const second = await handleCodeAgentChat( + { + conversationId: "cnv_stdio_ready", + traceId: "trc_stdio_ready_2", + message: "second" + }, + { + now: () => "2026-05-23T00:06:01.000Z", + codexStdioManager: manager, + env + } + ); + assert.equal(second.status, "completed"); + assert.equal(second.sessionId, first.sessionId); + assert.equal(second.sessionReuse.reused, true); + assert.equal(second.sessionReuse.turn, 2); + assert.equal(second.toolCalls[0].name, "codex-reply"); + assert.equal(second.reply.content, "second stdio reply"); + assert.equal(calls[0].name, "codex"); + assert.equal(calls[1].name, "codex-reply"); + assert.equal(calls[1].args.threadId, "thread_stdio_ready"); +}); diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs new file mode 100644 index 00000000..b78a52bb --- /dev/null +++ b/internal/cloud/codex-stdio-session.mjs @@ -0,0 +1,1139 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { accessSync, constants as fsConstants, existsSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const CODEX_STDIO_PROVIDER = "codex-stdio"; +export const CODEX_STDIO_BACKEND = "hwlab-cloud-api/codex-mcp-stdio"; +export const CODEX_STDIO_RUNNER_KIND = "codex-mcp-stdio-runner"; +export const CODEX_STDIO_SESSION_MODE = "codex-mcp-stdio-long-lived"; +export const CODEX_STDIO_IMPLEMENTATION_TYPE = "repo-owned-codex-mcp-stdio-session"; +export const CODEX_STDIO_CAPABILITY_LEVEL = "long-lived-codex-stdio-session"; +export const CODEX_STDIO_SANDBOX = "workspace-write"; +export const DEFAULT_CODEX_STDIO_IDLE_TIMEOUT_MS = 30 * 60 * 1000; +export const DEFAULT_CODEX_STDIO_MAX_SESSIONS = 64; +export const DEFAULT_CODEX_STDIO_COMMAND = "codex"; + +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_BOUNDARY_INSTRUCTIONS = [ + "You are the HWLAB Cloud Workbench Code Agent.", + "Use the provided workspace and repo-owned Codex stdio session only.", + "Do not read or print secrets, tokens, kubeconfig files, DB URLs, private keys, or raw environment values.", + "Do not call gateway, box-simu, or patch-panel directly.", + "Hardware control requests must go through cloud-api/HWLAB API/skill CLI controlled paths.", + "Do not claim M3, M4, or M5 acceptance unless the response includes live HWLAB evidence." +].join("\n"); + +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 idFactory = typeof options.idFactory === "function" ? options.idFactory : () => `ses_${randomUUID()}`; + const nowDefault = options.now; + const sessions = new Map(); + const conversations = new Map(); + let rpcClient = options.rpcClient ?? null; + let rpcStartedAt = null; + let rpcToolNames = null; + + function describe(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 enabled = codexStdioEnabled(env, params, options); + const supervisor = supervisorState(env, params, options, enabled); + const tokenBoundary = tokenBoundaryState(env); + const binaryOnPath = commandOnPathSync(command, env); + const workspaceInfo = workspaceStateSync(workspace, sandbox); + const egress = egressState(env); + const blockers = []; + + if (!enabled || supervisor.configured !== true) { + blockers.push({ + code: "codex_stdio_supervisor_disabled", + sourceIssue: "pikasTech/HWLAB#275", + summary: "Repo-owned Codex stdio supervisor is not enabled for this runtime." + }); + } + if (!binaryOnPath) { + blockers.push({ + code: "codex_cli_binary_missing", + sourceIssue: "pikasTech/HWLAB#275", + summary: `Codex CLI command ${command} is not present on the runtime PATH.` + }); + } + if (!workspaceInfo.exists || !workspaceInfo.readable) { + blockers.push({ + code: "workspace_mount_missing", + sourceIssue: "pikasTech/HWLAB#275", + summary: `Configured Codex workspace is not mounted/readable: ${workspace}.` + }); + } + if (sandbox === "workspace-write" && workspaceInfo.writable !== true) { + blockers.push({ + code: "workspace_write_boundary_blocked", + sourceIssue: "pikasTech/HWLAB#275", + summary: `Configured Codex workspace is not writable for workspace-write sandbox: ${workspace}.` + }); + } + if (!tokenBoundary.present) { + blockers.push({ + code: "provider_token_boundary", + sourceIssue: "pikasTech/HWLAB#275", + summary: "A long-lived Codex stdio runner needs a repo-owned provider token boundary; this endpoint only reports token presence and never reads or prints token material." + }); + } + if (egress.directPublicOpenAi) { + blockers.push({ + code: "codex_stdio_egress_boundary", + sourceIssue: "pikasTech/HWLAB#275", + summary: "Codex stdio provider egress must use the DEV egress/proxy boundary, not direct public api.openai.com." + }); + } + + const ready = blockers.length === 0; + return { + kind: CODEX_STDIO_RUNNER_KIND, + provider: CODEX_STDIO_PROVIDER, + backend: CODEX_STDIO_BACKEND, + status: ready ? "feasible" : "blocked", + ready, + canStartLongLivedCodexStdio: ready, + command, + binaryOnPath, + workspace, + workspaceState: workspaceInfo, + sandbox, + enabled, + supervisor, + tokenBoundary, + egress, + protocol: { + transport: "stdio", + command: `${command} mcp-server`, + protocolVersion: MCP_PROTOCOL_VERSION, + adapter: "Model Context Protocol tools/call", + requiredTools: [...CODEX_STDIO_REQUIRED_TOOLS], + toolsObserved: rpcToolNames ? [...rpcToolNames] : [] + }, + sessionLifecycle: { + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + create: true, + reuse: true, + cancel: true, + reap: true, + idleTimeout: true, + idleTimeoutMs, + traceCapture: true, + maxSessions, + activeSessions: sessions.size, + inMemoryIndex: true, + codexThreadIdCaptured: true, + valuesRedacted: true + }, + blockers, + blockerCodes: blockers.map((blocker) => blocker.code), + safety: codexStdioSafety() + }; + } + + async function chat(params = {}) { + const env = params.env ?? options.env ?? process.env; + const now = params.now ?? nowDefault; + const timestamp = timestampFor(now); + const traceId = optionalId(params.traceId) ?? `trc_${randomUUID()}`; + const conversationId = requiredId(params.conversationId, "cnv"); + const workspace = resolveCodexWorkspace(env, params); + const sandbox = resolveCodexSandbox(env, params); + const availability = describe({ ...params, env, workspace, sandbox }); + if (!availability.ready) { + throw codexStdioError("codex_stdio_blocked", "Codex stdio session is blocked by runtime readiness gates.", { + availability, + blockers: availability.blockers + }); + } + + const acquire = await acquireSession({ + conversationId, + requestedSessionId: params.sessionId, + traceId, + workspace, + sandbox, + now + }); + if (!acquire.ok) { + throw codexStdioError(acquire.code, acquire.message, { + availability, + session: acquire.session, + blockers: [acquire.blocker] + }); + } + + let session = acquire.session; + const events = [ + "stdio:acquire", + session.reused ? "session:reused" : "session:created", + session.threadId ? "codex-thread:reused" : "codex-thread:create", + `turn:${session.turn}` + ]; + const startedAt = timestamp; + + try { + const client = await ensureRpcClient({ env, availability, timeoutMs: params.timeoutMs }); + events.push("stdio:ready"); + const toolName = session.threadId ? "codex-reply" : "codex"; + const toolArguments = session.threadId + ? { + threadId: session.threadId, + prompt: buildCodexUserPrompt(params.message, { conversationId, traceId }) + } + : { + prompt: buildCodexUserPrompt(params.message, { conversationId, traceId }), + cwd: workspace, + sandbox, + model: params.model, + "approval-policy": "never", + "developer-instructions": CODEX_STDIO_BOUNDARY_INSTRUCTIONS + }; + const toolResult = await client.callTool(toolName, dropEmpty(toolArguments), effectiveTimeout(params.timeoutMs)); + const codexOutput = extractCodexToolOutput(toolResult); + if (!codexOutput.content) { + throw codexStdioError("codex_stdio_empty_response", "Codex stdio tool call completed without assistant content.", { + availability, + session + }); + } + session = releaseSession(session.sessionId, { + now, + traceId, + conversationId, + reused: session.reused, + threadId: codexOutput.threadId ?? session.threadId, + status: "idle" + }) ?? session; + events.push(`tool:${toolName}:completed`); + return { + 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, + workspace, + sandbox, + session, + sessionMode: CODEX_STDIO_SESSION_MODE, + sessionReuse: sessionReuseEvidence(session), + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], + codexStdioFeasibility: availability, + longLivedSessionGate: longLivedSessionGate({ + provider: CODEX_STDIO_PROVIDER, + runnerKind: CODEX_STDIO_RUNNER_KIND, + session, + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + codexStdioFeasibility: availability + }), + toolCalls: [{ + id: `tool_${randomUUID()}`, + type: "codex-stdio", + name: toolName, + status: "completed", + cwd: workspace, + command: "codex mcp-server tools/call", + exitCode: 0, + stdout: codexOutput.threadId ? `threadId=${codexOutput.threadId}` : "threadId=captured", + stderrSummary: "", + outputTruncated: false, + traceId + }], + skills: { + status: "not_requested", + items: [], + count: 0, + blockers: [] + }, + runner: runnerDescriptor({ workspace, sandbox, session }), + runnerTrace: runnerTrace({ + traceId, + workspace, + sandbox, + session, + events, + startedAt, + outputTruncated: false + }), + capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, + providerTrace: { + transport: "stdio", + protocol: "mcp", + command: `${availability.command} mcp-server`, + toolName, + threadId: codexOutput.threadId ?? session.threadId ?? null, + valuesPrinted: false + } + }; + } catch (error) { + session = failSession(session.sessionId, { + now, + traceId, + conversationId, + reused: session.reused, + statusReason: error.code ?? "codex_stdio_failed" + }) ?? session; + if (error.code && error.code.startsWith("codex_stdio")) { + error.session = session; + error.availability = error.availability ?? availability; + error.runnerTrace = runnerTrace({ + traceId, + workspace, + sandbox, + session, + events: [...events, `blocked:${error.code}`], + startedAt, + outputTruncated: false + }); + throw error; + } + throw codexStdioError("codex_stdio_failed", redactText(error.message || "Codex stdio session failed."), { + availability, + session, + runnerTrace: runnerTrace({ + traceId, + workspace, + sandbox, + session, + events: [...events, "blocked:codex_stdio_failed"], + startedAt, + outputTruncated: false + }) + }); + } + } + + function cancel(sessionId, params = {}) { + const session = sessions.get(requiredId(sessionId, "ses")) ?? null; + if (!session) return null; + const timestamp = timestampFor(params.now ?? nowDefault); + session.status = "interrupted"; + session.updatedAt = timestamp; + session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId; + session.currentTraceId = null; + session.statusReason = params.reason ?? "cancelled"; + if (rpcClient && typeof rpcClient.close === "function") { + rpcClient.close(); + rpcClient = null; + rpcStartedAt = null; + rpcToolNames = null; + } + return publicSession(session, { conversationId: params.conversationId, reused: true }); + } + + function reapIdle(params = {}) { + const timestamp = timestampFor(params.now ?? nowDefault); + const timestampMs = Date.parse(timestamp); + const reaped = []; + for (const session of sessions.values()) { + if (!sessionExpired(session, timestampMs)) continue; + session.status = "expired"; + session.updatedAt = timestamp; + session.currentTraceId = null; + reaped.push(session.sessionId); + } + if (sessionsBusyCount() === 0 && reaped.length > 0 && rpcClient && typeof rpcClient.close === "function") { + rpcClient.close(); + rpcClient = null; + rpcStartedAt = null; + rpcToolNames = null; + } + return { + reapedCount: reaped.length, + sessionIds: reaped + }; + } + + function get(sessionId, params = {}) { + const session = sessions.get(requiredId(sessionId, "ses")) ?? null; + return session ? publicSession(session, params) : null; + } + + function clear() { + sessions.clear(); + conversations.clear(); + if (rpcClient && typeof rpcClient.close === "function") rpcClient.close(); + rpcClient = null; + rpcStartedAt = null; + rpcToolNames = null; + } + + async function ensureRpcClient({ env, availability, timeoutMs } = {}) { + if (rpcClient && typeof rpcClient.callTool === "function") return rpcClient; + rpcClient = options.createRpcClient + ? await options.createRpcClient({ env, availability }) + : createCodexMcpJsonLineClient({ + command: availability.command, + env: childProcessEnv(env), + cwd: availability.workspace + }); + if (typeof rpcClient.initialize === "function") { + const init = await rpcClient.initialize(effectiveTimeout(timeoutMs)); + rpcToolNames = Array.isArray(init?.tools) ? init.tools : rpcToolNames; + } + if (!rpcToolNames && typeof rpcClient.listTools === "function") { + rpcToolNames = await rpcClient.listTools(effectiveTimeout(timeoutMs)); + } + const missingTools = CODEX_STDIO_REQUIRED_TOOLS.filter((name) => !rpcToolNames?.includes(name)); + if (missingTools.length > 0) { + throw codexStdioError("codex_stdio_protocol_blocked", `Codex stdio server did not expose required tools: ${missingTools.join(", ")}.`, { + availability, + missingTools + }); + } + rpcStartedAt = timestampFor(nowDefault); + return rpcClient; + } + + async function acquireSession(params = {}) { + const timestamp = timestampFor(params.now ?? nowDefault); + const timestampMs = Date.parse(timestamp); + const conversationId = requiredId(params.conversationId, "cnv"); + const requestedSessionId = optionalId(params.requestedSessionId); + const mappedSessionId = conversations.get(conversationId) ?? null; + + if (mappedSessionId && requestedSessionId && mappedSessionId !== requestedSessionId) { + const mappedSession = sessions.get(mappedSessionId) ?? null; + return blockedAcquire({ + code: "session_reuse_conflict", + summary: `conversationId ${conversationId} is already bound to sessionId ${mappedSessionId}; requested ${requestedSessionId}.`, + session: mappedSession, + timestamp, + traceId: params.traceId + }); + } + + const effectiveSessionId = mappedSessionId || requestedSessionId || requiredId(idFactory(), "ses"); + let session = sessions.get(effectiveSessionId) ?? null; + const reused = Boolean(session); + + if (session && sessionExpired(session, timestampMs)) { + session.status = "expired"; + session.updatedAt = timestamp; + session.currentTraceId = null; + session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId; + return blockedAcquire({ + code: "session_expired", + summary: `Codex stdio session ${effectiveSessionId} expired after ${session.idleTimeoutMs}ms idle timeout.`, + session, + timestamp, + traceId: params.traceId + }); + } + + if (session?.status === "busy") { + return blockedAcquire({ + code: "session_busy", + summary: `Codex stdio session ${effectiveSessionId} is already busy with traceId ${session.currentTraceId ?? "unknown"}.`, + session, + timestamp, + traceId: params.traceId + }); + } + + if (!session) { + session = { + sessionId: effectiveSessionId, + conversationIds: new Set(), + status: "creating", + workspace: params.workspace, + sandbox: params.sandbox, + runnerKind: CODEX_STDIO_RUNNER_KIND, + capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + createdAt: timestamp, + updatedAt: timestamp, + idleTimeoutMs, + expiresAt: plusMs(timestampMs, idleTimeoutMs), + lastTraceId: optionalId(params.traceId), + currentTraceId: null, + turn: 0, + threadId: null, + durable: true, + longLivedSession: true, + codexStdio: true, + writeCapable: true, + secretMaterialStored: false + }; + sessions.set(effectiveSessionId, session); + } + + session.conversationIds.add(conversationId); + session.workspace = params.workspace ?? session.workspace; + session.sandbox = params.sandbox ?? session.sandbox; + session.status = "busy"; + session.updatedAt = timestamp; + session.expiresAt = plusMs(timestampMs, idleTimeoutMs); + session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId; + session.currentTraceId = optionalId(params.traceId); + session.turn += 1; + conversations.set(conversationId, effectiveSessionId); + pruneSessions(); + + return { + ok: true, + reused, + session: publicSession(session, { conversationId, reused }) + }; + } + + function releaseSession(sessionId, params = {}) { + const session = sessions.get(requiredId(sessionId, "ses")) ?? null; + if (!session) return null; + const timestamp = timestampFor(params.now ?? nowDefault); + const timestampMs = Date.parse(timestamp); + session.status = params.status ?? "idle"; + session.updatedAt = timestamp; + session.expiresAt = plusMs(timestampMs, idleTimeoutMs); + session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId; + session.currentTraceId = null; + session.threadId = optionalId(params.threadId) ?? session.threadId; + session.statusReason = params.statusReason ?? null; + return publicSession(session, { + conversationId: params.conversationId, + reused: params.reused + }); + } + + function failSession(sessionId, params = {}) { + return releaseSession(sessionId, { ...params, status: "failed" }); + } + + function pruneSessions() { + if (sessions.size <= maxSessions) return; + const sorted = [...sessions.entries()] + .sort((a, b) => String(a[1].updatedAt).localeCompare(String(b[1].updatedAt))); + for (const [sessionId, session] of sorted.slice(0, sessions.size - maxSessions)) { + sessions.delete(sessionId); + for (const conversationId of session.conversationIds) { + if (conversations.get(conversationId) === sessionId) conversations.delete(conversationId); + } + } + } + + function sessionsBusyCount() { + return [...sessions.values()].filter((session) => session.status === "busy").length; + } + + return { + describe, + chat, + cancel, + reapIdle, + get, + clear, + longLivedSessionGate, + get rpcStartedAt() { + return rpcStartedAt; + } + }; +} + +export function createCodexMcpJsonLineClient({ command = DEFAULT_CODEX_STDIO_COMMAND, env = process.env, cwd = repoRoot } = {}) { + const child = spawn(command, ["mcp-server"], { + cwd, + env, + stdio: ["pipe", "pipe", "pipe"] + }); + let nextId = 1; + let stdoutBuffer = ""; + let stderr = ""; + const pending = new Map(); + let initialized = false; + + child.stdout.on("data", (chunk) => { + stdoutBuffer += chunk.toString("utf8"); + let newlineIndex = stdoutBuffer.indexOf("\n"); + while (newlineIndex >= 0) { + const line = stdoutBuffer.slice(0, newlineIndex).trim(); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + if (line) handleLine(line); + newlineIndex = stdoutBuffer.indexOf("\n"); + } + }); + child.stderr.on("data", (chunk) => { + stderr = tailText(`${stderr}${chunk.toString("utf8")}`, 4000); + }); + child.on("error", (error) => rejectAll(`Codex stdio process error: ${error.message}`)); + child.on("close", (code, signal) => rejectAll(`Codex stdio process closed code=${code ?? "null"} signal=${signal ?? "null"}`)); + + function handleLine(line) { + let payload = null; + try { + payload = JSON.parse(line); + } catch { + return; + } + const request = pending.get(payload.id); + if (!request) return; + pending.delete(payload.id); + clearTimeout(request.timer); + if (payload.error) { + request.reject(new Error(redactText(payload.error.message ?? JSON.stringify(payload.error)))); + } else { + request.resolve(payload.result); + } + } + + function rejectAll(message) { + for (const [id, request] of pending.entries()) { + pending.delete(id); + clearTimeout(request.timer); + request.reject(new Error(redactText(`${message}; stderr=${tailText(stderr, 800)}`))); + } + } + + function request(method, params = {}, timeoutMs = 30000) { + const id = nextId; + nextId += 1; + const message = JSON.stringify({ + jsonrpc: "2.0", + id, + method, + params + }); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Codex stdio request ${method} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + pending.set(id, { resolve, reject, timer }); + child.stdin.write(`${message}\n`, "utf8", (error) => { + if (!error) return; + pending.delete(id); + clearTimeout(timer); + reject(error); + }); + }); + } + + async function initialize(timeoutMs = 30000) { + if (initialized) return { tools: await listTools(timeoutMs) }; + await request("initialize", { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: "hwlab-cloud-api", + version: "0" + } + }, timeoutMs); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`); + initialized = true; + return { tools: await listTools(timeoutMs) }; + } + + async function listTools(timeoutMs = 30000) { + const result = await request("tools/list", {}, timeoutMs); + return Array.isArray(result?.tools) ? result.tools.map((tool) => tool?.name).filter(Boolean) : []; + } + + async function callTool(name, args = {}, timeoutMs = 120000) { + return request("tools/call", { + name, + arguments: args + }, timeoutMs); + } + + function close() { + child.kill("SIGTERM"); + } + + return { + initialize, + listTools, + callTool, + close + }; +} + +export function longLivedSessionGate({ + provider, + runnerKind, + session, + sessionMode, + implementationType, + codexStdioFeasibility +} = {}) { + const normalizedProvider = String(provider ?? "").trim(); + const normalizedRunnerKind = String(runnerKind ?? "").trim(); + const normalizedSessionMode = String(sessionMode ?? "").trim(); + const normalizedImplementation = String(implementationType ?? "").trim(); + const blockers = []; + if (normalizedProvider === "openai-responses" || normalizedRunnerKind === "openai-responses-fallback") { + blockers.push({ + code: "openai_responses_fallback_not_session", + sourceIssue: "pikasTech/HWLAB#317", + summary: "OpenAI Responses fallback is text-chat-only and cannot pass the long-lived Codex session gate." + }); + } + if (normalizedRunnerKind === "codex-cli-one-shot-ephemeral" || normalizedSessionMode === "ephemeral-one-shot") { + blockers.push({ + code: "one_shot_runner_not_long_lived", + sourceIssue: "pikasTech/HWLAB#317", + summary: "codex exec --ephemeral / one-shot runner output is not a reusable long-lived runner session." + }); + } + if (normalizedImplementation === "controlled-readonly-session-registry") { + blockers.push({ + code: "controlled_readonly_not_long_lived_stdio", + sourceIssue: "pikasTech/HWLAB#317", + summary: "This response is backed by the controlled read-only session registry, not Codex stdio or an equivalent long-lived protocol adapter." + }); + } + for (const blocker of codexStdioFeasibility?.blockers ?? []) { + if (!blocker?.code || blockers.some((item) => item.code === blocker.code)) continue; + blockers.push({ + code: blocker.code, + sourceIssue: blocker.sourceIssue ?? "pikasTech/HWLAB#317", + summary: blocker.summary ?? `Codex stdio feasibility blocker: ${blocker.code}.` + }); + } + + const feasible = codexStdioFeasibility?.canStartLongLivedCodexStdio === true || codexStdioFeasibility?.ready === true; + const sessionPass = + normalizedProvider === CODEX_STDIO_PROVIDER && + normalizedRunnerKind === CODEX_STDIO_RUNNER_KIND && + normalizedSessionMode === CODEX_STDIO_SESSION_MODE && + normalizedImplementation === CODEX_STDIO_IMPLEMENTATION_TYPE && + session?.longLivedSession === true && + session?.codexStdio === true && + session?.writeCapable === true && + session?.durable === true && + feasible && + blockers.length === 0; + const feasiblePass = + normalizedProvider === CODEX_STDIO_PROVIDER && + normalizedRunnerKind === CODEX_STDIO_RUNNER_KIND && + normalizedSessionMode === CODEX_STDIO_SESSION_MODE && + normalizedImplementation === CODEX_STDIO_IMPLEMENTATION_TYPE && + !session && + feasible && + blockers.length === 0; + const pass = sessionPass || feasiblePass; + + return { + status: pass ? "pass" : "blocked", + pass, + requiredCapability: "long-lived-codex-stdio-session", + currentCapability: normalizedImplementation || normalizedSessionMode || normalizedRunnerKind || "unknown", + sessionId: session?.sessionId ?? null, + sessionStatus: session?.status ?? null, + runnerKind: normalizedRunnerKind || null, + provider: normalizedProvider || null, + sessionMode: normalizedSessionMode || null, + implementationType: normalizedImplementation || null, + blockers, + sourceIssue: "pikasTech/HWLAB#317", + summary: pass + ? "Long-lived Codex stdio/session gate passed." + : "Long-lived Codex stdio/session gate remains blocked; current response must not be reported as full Code Agent completion." + }; +} + +function publicSession(session, { conversationId = null, reused = null } = {}) { + const conversationIds = [...session.conversationIds]; + return { + sessionId: session.sessionId, + conversationId: conversationId ?? conversationIds[0] ?? null, + conversationIds, + status: session.status, + workspace: session.workspace, + sandbox: session.sandbox, + runnerKind: session.runnerKind, + capabilityLevel: session.capabilityLevel, + implementationType: session.implementationType, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + idleTimeoutMs: session.idleTimeoutMs, + expiresAt: session.expiresAt, + lastTraceId: session.lastTraceId, + currentTraceId: session.currentTraceId, + turn: session.turn, + threadId: session.threadId ?? null, + reused: reused === null ? undefined : Boolean(reused), + durable: session.durable === true, + longLivedSession: session.longLivedSession === true, + codexStdio: session.codexStdio === true, + writeCapable: session.writeCapable === true, + secretMaterialStored: false, + valuesRedacted: true, + ...(session.statusReason ? { statusReason: session.statusReason } : {}) + }; +} + +function blockedAcquire({ code, summary, session, timestamp, traceId }) { + const publicEvidence = session + ? publicSession(session, { timestamp, reused: true }) + : { + status: "failed", + updatedAt: timestamp, + lastTraceId: optionalId(traceId), + secretMaterialStored: false, + valuesRedacted: true + }; + return { + ok: false, + code, + message: summary, + session: publicEvidence, + blocker: { + code, + sourceIssue: "pikasTech/HWLAB#317", + summary + } + }; +} + +function runnerDescriptor({ workspace, sandbox, session }) { + return { + kind: CODEX_STDIO_RUNNER_KIND, + provider: CODEX_STDIO_PROVIDER, + backend: CODEX_STDIO_BACKEND, + workspace, + sandbox, + session: CODEX_STDIO_SESSION_MODE, + sessionMode: CODEX_STDIO_SESSION_MODE, + sessionId: session?.sessionId ?? null, + turn: session?.turn ?? null, + sessionReused: session?.reused ?? false, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + codexStdio: true, + longLivedSession: true, + durableSession: true, + writeCapable: true, + readOnly: false, + capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, + toolPolicy: { + allowed: ["codex", "codex-reply", "workspace-read", "workspace-write"], + blocked: ["secret-read", "kubeconfig-read", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim-without-evidence"] + }, + runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], + safety: codexStdioSafety() + }; +} + +function runnerTrace({ traceId, workspace, sandbox, session, events, startedAt, outputTruncated }) { + return { + traceId, + runnerKind: CODEX_STDIO_RUNNER_KIND, + workspace, + sandbox, + sessionMode: CODEX_STDIO_SESSION_MODE, + sessionId: session?.sessionId ?? null, + sessionStatus: session?.status ?? null, + idleTimeoutMs: session?.idleTimeoutMs ?? null, + lastTraceId: session?.lastTraceId ?? null, + turn: session?.turn ?? null, + sessionReused: session?.reused ?? false, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + limitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], + startedAt, + finishedAt: timestampFor(), + events, + outputTruncated: Boolean(outputTruncated), + valuesPrinted: false, + note: "Repo-owned Codex MCP stdio supervisor manages create/reuse/cancel/reap/idle timeout and trace capture; hardware control remains routed through cloud-api/HWLAB API/skill CLI boundaries." + }; +} + +function sessionReuseEvidence(session) { + return { + conversationId: session.conversationId, + sessionId: session.sessionId, + threadId: session.threadId, + mapped: true, + reused: session.reused, + turn: session.turn, + previousTurns: Math.max(0, session.turn - 1), + workspace: session.workspace, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + idleTimeoutMs: session.idleTimeoutMs, + expiresAt: session.expiresAt, + lastTraceId: session.lastTraceId, + status: session.status + }; +} + +function extractCodexToolOutput(toolResult) { + const direct = toolResult?.structuredContent ?? toolResult?.output ?? toolResult; + const directThreadId = optionalId(direct?.threadId ?? direct?.conversationId); + const directContent = firstNonEmpty(direct?.content, direct?.message, direct?.text); + if (directThreadId || directContent) { + return { + threadId: directThreadId, + content: redactText(directContent) + }; + } + + for (const item of toolResult?.content ?? []) { + if (typeof item?.text !== "string") continue; + const parsed = parseJsonOrNull(item.text); + if (parsed) { + const threadId = optionalId(parsed.threadId ?? parsed.conversationId); + const content = firstNonEmpty(parsed.content, parsed.message, parsed.text); + if (threadId || content) { + return { + threadId, + content: redactText(content) + }; + } + } + if (item.text.trim()) { + return { + threadId: null, + content: redactText(item.text.trim()) + }; + } + } + + return { + threadId: null, + content: "" + }; +} + +function buildCodexUserPrompt(message, { conversationId, traceId }) { + return [ + `conversationId: ${conversationId}`, + `traceId: ${traceId}`, + "", + "User message:", + String(message ?? "").trim() + ].join("\n"); +} + +function resolveCodexWorkspace(env = process.env, options = {}) { + return path.resolve(firstNonEmpty( + options.workspace, + env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, + env.HWLAB_CODE_AGENT_WORKSPACE, + env.HWLAB_RUNNER_WORKSPACE, + env.WORKSPACE, + repoRoot + )); +} + +function resolveCodexCommand(env = process.env, params = {}, options = {}) { + const configured = firstNonEmpty(params.command, options.command, env.HWLAB_CODE_AGENT_CODEX_COMMAND); + if (configured) return configured; + + const nodeModulesCodex = path.join(repoRoot, "node_modules", ".bin", "codex"); + if (existsSync(nodeModulesCodex)) return nodeModulesCodex; + return DEFAULT_CODEX_STDIO_COMMAND; +} + +function resolveCodexSandbox(env = process.env, options = {}) { + const value = firstNonEmpty(options.sandbox, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, CODEX_STDIO_SANDBOX); + return ["read-only", "workspace-write"].includes(value) ? value : CODEX_STDIO_SANDBOX; +} + +function codexStdioEnabled(env, params, options) { + if (params.enabled === true || options.enabled === true) return true; + return env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED === "1" || + env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED === "true"; +} + +function supervisorState(env, params, options, enabled) { + const configured = params.supervisorEnabled === true || + options.supervisorEnabled === true || + env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR === "repo-owned" || + env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR === "enabled" || + (enabled && env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR !== "disabled"); + return { + configured, + mode: configured ? "repo-owned-node-supervisor" : "disabled", + processModel: "codex mcp-server over stdio", + cancelSupported: true, + reapSupported: true, + secretMaterialStored: false, + valuesRedacted: true + }; +} + +function tokenBoundaryState(env = process.env) { + const present = hasEnvValue(env, "OPENAI_API_KEY") || + hasEnvValue(env, "CODEX_API_KEY") || + hasEnvValue(env, "CODEX_HOME") || + env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY === "configured" || + env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY === "present"; + return { + present, + sources: [ + hasEnvValue(env, "OPENAI_API_KEY") ? "OPENAI_API_KEY" : null, + hasEnvValue(env, "CODEX_API_KEY") ? "CODEX_API_KEY" : null, + hasEnvValue(env, "CODEX_HOME") ? "CODEX_HOME" : null, + env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY ? "HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY" : null + ].filter(Boolean), + secretMaterialRead: false, + secretValuesPrinted: false, + valuesRedacted: true + }; +} + +function egressState(env = process.env) { + const baseUrl = firstNonEmpty(env.HWLAB_CODE_AGENT_OPENAI_BASE_URL, env.OPENAI_BASE_URL); + const directPublicOpenAi = /^https:\/\/api\.openai\.com\/v1\/?/u.test(baseUrl); + return { + configured: Boolean(baseUrl), + directPublicOpenAi, + valueRedacted: true + }; +} + +function workspaceStateSync(workspace, sandbox) { + const state = { + exists: false, + readable: false, + writable: false, + writeRequired: sandbox === "workspace-write" + }; + try { + state.exists = existsSync(workspace); + if (!state.exists) return state; + state.readable = accessSyncBoolean(workspace, fsConstants.R_OK); + state.writable = accessSyncBoolean(workspace, fsConstants.W_OK); + return state; + } catch { + return state; + } +} + +function accessSyncBoolean(target, mode) { + try { + accessSync(target, mode); + return true; + } catch { + return false; + } +} + +function commandOnPathSync(command, env = process.env) { + if (!command) return false; + if (command.includes("/") || command.includes("\\")) { + return existsSync(command); + } + const paths = String(Object.hasOwn(env, "PATH") ? env.PATH : process.env.PATH || "").split(path.delimiter).filter(Boolean); + return paths.some((dir) => existsSync(path.join(dir, command))); +} + +function childProcessEnv(env = process.env) { + return { + PATH: Object.hasOwn(env, "PATH") ? env.PATH : process.env.PATH || "/usr/local/bin:/usr/bin:/bin", + HOME: env.HOME || process.env.HOME || os.homedir(), + ...(env.CODEX_HOME ? { CODEX_HOME: env.CODEX_HOME } : {}), + ...(env.OPENAI_API_KEY ? { OPENAI_API_KEY: env.OPENAI_API_KEY } : {}), + ...(env.CODEX_API_KEY ? { CODEX_API_KEY: env.CODEX_API_KEY } : {}), + ...(env.HWLAB_CODE_AGENT_OPENAI_BASE_URL ? { OPENAI_BASE_URL: env.HWLAB_CODE_AGENT_OPENAI_BASE_URL } : {}), + ...(env.OPENAI_BASE_URL ? { OPENAI_BASE_URL: env.OPENAI_BASE_URL } : {}) + }; +} + +function codexStdioSafety() { + return { + secretsRead: false, + secretValuesPrinted: false, + kubeconfigRead: false, + prodTouched: false, + hardwareWritesAllowed: false, + directGatewayCallsAllowed: false, + directBoxSimuCallsAllowed: false, + directPatchPanelCallsAllowed: false, + hardwareControlPath: "cloud-api/HWLAB API/skill CLI only", + valuesRedacted: true + }; +} + +function codexStdioError(code, message, details = {}) { + const error = new Error(message); + error.code = code; + Object.assign(error, { + provider: CODEX_STDIO_PROVIDER, + backend: CODEX_STDIO_BACKEND, + capabilityLevel: "blocked", + ...details + }); + return error; +} + +function sessionExpired(session, timestampMs) { + if (!Number.isFinite(timestampMs) || session.status === "expired") return session.status === "expired"; + return Date.parse(session.expiresAt) <= timestampMs; +} + +function timestampFor(now) { + const value = typeof now === "function" ? now() : now; + if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return value; + if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString(); + return new Date().toISOString(); +} + +function plusMs(timestampMs, offsetMs) { + return new Date(timestampMs + offsetMs).toISOString(); +} + +function requiredId(value, fallbackPrefix) { + const id = optionalId(value); + if (id) return id; + return `${fallbackPrefix}_${randomUUID()}`; +} + +function optionalId(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function positiveInteger(value, fallback) { + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +function firstNonEmpty(...values) { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; +} + +function hasEnvValue(env, name) { + return typeof env?.[name] === "string" && env[name].trim().length > 0; +} + +function effectiveTimeout(timeoutMs) { + return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120000; +} + +function dropEmpty(value) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null && item !== "")); +} + +function parseJsonOrNull(value) { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function tailText(value, maxLength = 1200) { + const text = String(value ?? "").trim(); + if (text.length <= maxLength) return text; + return text.slice(text.length - maxLength); +} + +function redactText(value) { + return String(value ?? "") + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer ***") + .replace(/sk-[A-Za-z0-9._-]+/gu, "sk-***") + .replace(/([A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*=)[^\s]+/giu, "$1***") + .replace(/([A-Za-z0-9_]*KEY[A-Za-z0-9_]*=)[^\s]+/giu, "$1***"); +} diff --git a/internal/cloud/health-contract.mjs b/internal/cloud/health-contract.mjs index 025e4dbd..b4a6061c 100644 --- a/internal/cloud/health-contract.mjs +++ b/internal/cloud/health-contract.mjs @@ -58,7 +58,11 @@ function isDbReady(db) { } function isCodeAgentReady(codeAgent) { - return Boolean(codeAgent?.ready && codeAgent?.status !== "blocked"); + return Boolean( + codeAgent?.ready === true && + codeAgent?.longLivedSessionGate?.status === "pass" && + codeAgent?.codexStdioFeasibility?.canStartLongLivedCodexStdio === true + ); } function isRuntimeReady(runtime) { @@ -91,16 +95,22 @@ function dbBlocker(db) { } function codeAgentBlocker(codeAgent) { + const primaryBlocker = Array.isArray(codeAgent?.blockers) ? codeAgent.blockers[0] : null; return { - code: codeAgent?.reason ?? "code_agent_provider_unavailable", + code: codeAgent?.reason ?? primaryBlocker?.code ?? "code_agent_long_lived_session_blocked", type: "agent_blocker", - scope: "code-agent-provider", + scope: "code-agent-long-lived-session", status: "open", - sourceIssue: "pikasTech/HWLAB#143", + sourceIssue: primaryBlocker?.sourceIssue ?? "pikasTech/HWLAB#275", summary: codeAgent?.summary ?? "Code Agent provider readiness is not established", evidence: { provider: codeAgent?.provider ?? "unknown", backend: codeAgent?.backend ?? "unknown", + agentKind: codeAgent?.agentKind ?? "unknown", + capabilityStatus: codeAgent?.capabilityStatus ?? "unknown", + longLivedSessionGate: codeAgent?.longLivedSessionGate ?? null, + codexStdioFeasibility: codeAgent?.codexStdioFeasibility ?? null, + partialReady: codeAgent?.partialReady === true, missingEnv: Array.isArray(codeAgent?.missingEnv) ? [...codeAgent.missingEnv] : [], secretsRedacted: true } diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index c24c51a7..1f53f19f 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -345,7 +345,8 @@ async function handleCodeAgentChatHttp(request, response, options) { skillsDirs: options.skillsDirs, skillsDirsExact: options.skillsDirsExact, sessionRegistry: options.sessionRegistry, - m3IoSkillRequestJson: options.m3IoSkillRequestJson + m3IoSkillRequestJson: options.m3IoSkillRequestJson, + codexStdioManager: options.codexStdioManager } ); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 13c81cad..70b8c495 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -68,15 +68,17 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthPayload.readiness.durability.dbLiveEvidenceObserved, false); assert.equal(healthPayload.readiness.durability.dbLiveEvidenceIsDurabilityEvidence, false); assert.equal(healthPayload.readiness.durability.blockedLayer, "adapter"); - assert.equal(healthPayload.codeAgent.status, "available"); - assert.equal(healthPayload.codeAgent.blocker, null); - assert.equal(healthPayload.codeAgent.reason, null); + assert.equal(healthPayload.codeAgent.status, "partial"); + assert.equal(healthPayload.codeAgent.agentKind, "controlled-readonly-session-registry"); + assert.equal(healthPayload.codeAgent.partialReady, true); + assert.match(healthPayload.codeAgent.blocker, /long-lived Codex stdio|Codex stdio/u); + assert.equal(healthPayload.codeAgent.reason, "codex_stdio_supervisor_disabled"); assert.equal(healthPayload.codeAgent.runner.kind, "hwlab-readonly-runner"); assert.equal(healthPayload.codeAgent.runner.ready, true); 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 === "stdio_protocol_not_wired")); + assert.ok(healthPayload.codeAgent.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_stdio_supervisor_disabled")); 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"); @@ -95,8 +97,9 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthLivePayload.status, "degraded"); assert.equal(healthLivePayload.commit.id.length > 0, true); assert.equal(healthLivePayload.db.ready, false); - assert.equal(healthLivePayload.codeAgent.status, "available"); - assert.equal(healthLivePayload.codeAgent.ready, true); + assert.equal(healthLivePayload.codeAgent.status, "partial"); + assert.equal(healthLivePayload.codeAgent.ready, false); + assert.equal(healthLivePayload.codeAgent.partialReady, true); assert.equal(healthLivePayload.codeAgent.capabilityLevel, "read-only-session-tools"); const readiness = await fetch(`http://127.0.0.1:${port}/health/live`); @@ -846,9 +849,9 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret 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.status, "available"); - assert.equal(payload.codeAgent.blocker, null); - assert.equal(payload.codeAgent.reason, null); + assert.equal(payload.codeAgent.status, "partial"); + assert.match(payload.codeAgent.blocker, /Codex stdio|long-lived/u); + assert.equal(payload.codeAgent.reason, "codex_stdio_supervisor_disabled"); assert.equal(payload.codeAgent.runner.kind, "hwlab-readonly-runner"); assert.equal(payload.codeAgent.runner.ready, true); assert.equal(payload.codeAgent.capabilityLevel, "read-only-session-tools"); @@ -859,7 +862,8 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret assert.equal(payload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider"); assert.equal(payload.codeAgent.secretRefs[0].secretKey, "openai-api-key"); assert.equal(payload.codeAgent.secretRefs[0].redacted, true); - assert.equal(payload.codeAgent.ready, true); + assert.equal(payload.codeAgent.ready, false); + assert.equal(payload.codeAgent.partialReady, true); assert.equal(payload.codeAgent.egress.present, false); assert.equal(payload.codeAgent.egress.valueRedacted, true); assert.equal(payload.codeAgent.safety.secretMaterialRead, false); @@ -889,9 +893,9 @@ test("cloud api /v1 describes Code Agent egress blocker without leaking API key" const response = await fetch(`http://127.0.0.1:${port}/v1`); assert.equal(response.status, 200); const payload = await response.json(); - assert.equal(payload.codeAgent.status, "available"); + assert.equal(payload.codeAgent.status, "partial"); assert.equal(payload.codeAgent.egress.directPublicOpenAi, true); - assert.equal(payload.codeAgent.blocker, null); + assert.match(payload.codeAgent.blocker, /Codex stdio|long-lived/u); assert.equal(payload.codeAgent.runner.ready, true); assert.equal(payload.codeAgent.safety.secretMaterialRead, false); assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); @@ -1003,9 +1007,9 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence assert.equal(payload.sessionReuse.turn, 1); assert.equal(payload.sessionReuse.status, "idle"); assert.ok(payload.runnerLimitations.includes("not-codex-stdio")); - assert.ok(payload.codexStdioFeasibility.blockers.some((blocker) => blocker.code === "runner_lifecycle_missing")); + assert.ok(payload.codexStdioFeasibility.blockers.some((blocker) => blocker.code === "codex_stdio_supervisor_disabled")); assert.equal(payload.longLivedSessionGate.status, "blocked"); - assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired")); + assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "controlled_readonly_not_long_lived_stdio")); assert.equal(payload.toolCalls[0].name, "pwd"); assert.equal(payload.toolCalls[0].status, "completed"); assert.equal(payload.toolCalls[0].stdout, workspace); @@ -1694,9 +1698,9 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as assert.match(payload.error.message, /Codex CLI command is not available/); assert.deepEqual(payload.error.missingCommands, ["codex"]); assert.ok(payload.error.missingEnv.includes("OPENAI_API_KEY")); - assert.equal(payload.availability.status, "available"); - assert.equal(payload.availability.blocker, null); - assert.equal(payload.availability.reason, null); + assert.equal(payload.availability.status, "partial"); + assert.match(payload.availability.blocker, /Codex stdio|long-lived/u); + assert.equal(payload.availability.reason, "codex_stdio_supervisor_disabled"); assert.equal(payload.availability.runner.ready, true); assert.equal(payload.availability.secretRefs[0].secretName, "hwlab-code-agent-provider"); assert.equal(payload.availability.secretRefs[0].secretKey, "openai-api-key"); diff --git a/package-lock.json b/package-lock.json index 33926997..1f2ee411 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,133 @@ "name": "hwlab", "version": "0.0.0-l0", "dependencies": { + "@openai/codex": "^0.128.0", "pg": "^8.21.0", "playwright": "1.59.1" } }, + "node_modules/@openai/codex": { + "version": "0.128.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0.tgz", + "integrity": "sha512-+xp6ODmFfBNnexIWRHApEaPXot2j6gyM8A5we/5IS/uY4eYHj4arETct4hQ5M4eO+MK7JY3ZU4xhuobhlysr0A==", + "license": "Apache-2.0", + "bin": { + "codex": "bin/codex.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.128.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.128.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.128.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.128.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.128.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.128.0-win32-x64" + } + }, + "node_modules/@openai/codex-darwin-arm64": { + "name": "@openai/codex", + "version": "0.128.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-darwin-arm64.tgz", + "integrity": "sha512-w+6zohfHx/kHBdles/CyFKaY57u9I3nK8QI9+NrdwMliKA0b7xn13yblRNkMpe09j6vL1oAWoxYsMOQ/vjBGug==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-darwin-x64": { + "name": "@openai/codex", + "version": "0.128.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-darwin-x64.tgz", + "integrity": "sha512-SDbn6fO22Puy8xmMIbZi4f2znMrUEPwABApke4mo+4ihaauwuVjeqzXvW5SPJz5ty/bG11/mSupQgReT7T8BBw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-arm64": { + "name": "@openai/codex", + "version": "0.128.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-linux-arm64.tgz", + "integrity": "sha512-+SvH73H60qvCXFuQGP/EsmR//s1hHMBR22PvJkXvM/hdnTIGucx+JqRUjAWdmmQ1IU6j3kgwVvdLW/6ICB+M6w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-x64": { + "name": "@openai/codex", + "version": "0.128.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-linux-x64.tgz", + "integrity": "sha512-2lnSPA05CRRuKAzFW8BCmmNCSieDcToLwfC2ALLbBYilGLgzhRibjlDglK9F1BkEzfohSSWJu4PBbRu/aG60lQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-arm64": { + "name": "@openai/codex", + "version": "0.128.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-win32-arm64.tgz", + "integrity": "sha512-ECJvsqmYFdA9pn42xxK3Odp/G16AjmBW0BglX8L0PwPjqbstbmlew9bfHf7xvL+SNfNl4NmyotW0+RNo1phgaA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-x64": { + "name": "@openai/codex", + "version": "0.128.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-win32-x64.tgz", + "integrity": "sha512-k3jmUAFrzkUtvjGTXvSKjQqJLLlzjxp/VoHJDYedgmXUn6j70HxK38IwapzmnYfiBiTuzETvGwjXHzZgzKjhoQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", diff --git a/package.json b/package.json index 6a4f8470..a04324eb 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test web/hwlab-cloud-web/wiring-status.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test web/hwlab-cloud-web/wiring-status.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", @@ -44,6 +44,7 @@ "web:layout:live": "node scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-layout-live.json" }, "dependencies": { + "@openai/codex": "^0.128.0", "pg": "^8.21.0", "playwright": "1.59.1" } diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index bb5d65f3..7681128b 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -117,9 +117,9 @@ async function runLocalContractSmoke() { assert.match(failed.error.message, /Codex CLI command is not available/); assert.deepEqual(failed.error.missingCommands, ["codex"]); assert.ok(failed.error.missingEnv.includes("OPENAI_API_KEY")); - assert.equal(failed.availability.status, "available"); - assert.equal(failed.availability.blocker, null); - assert.equal(failed.availability.reason, null); + assert.equal(failed.availability.status, "partial"); + assert.match(failed.availability.blocker, /Codex stdio|long-lived/u); + assert.equal(failed.availability.reason, "codex_stdio_supervisor_disabled"); assert.equal(failed.availability.runner.ready, true); assert.match(failed.availability.summary, /受控只读 runner/u); assert.match(failed.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u); @@ -198,14 +198,14 @@ async function runLocalContractSmoke() { assert.equal(runnerPwd.codexStdioFeasibility.status, "blocked"); assert.equal(runnerPwd.longLivedSessionGate.status, "blocked"); assert.equal(runnerPwd.longLivedSessionGate.pass, false); - assert.ok(runnerPwd.longLivedSessionGate.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired")); + assert.ok(runnerPwd.longLivedSessionGate.blockers.some((blocker) => blocker.code === "controlled_readonly_not_long_lived_stdio")); assert.equal(runnerPwd.toolCalls[0].name, "pwd"); assert.equal(runnerPwd.toolCalls[0].status, "completed"); const runnerCapability = classifyCodexRunnerCapability(runnerPwd, { httpStatus: 200 }); - assert.equal(runnerCapability.status, "pass"); - assert.equal(runnerCapability.capabilityPass, true); - assert.equal(runnerCapability.longLivedSession, false); - logOk("read-only runner pwd capability"); + assert.equal(runnerCapability.status, "blocked"); + assert.equal(runnerCapability.capabilityPass, false); + assert.equal(runnerCapability.blocker, "controlled-readonly-not-long-lived"); + logOk("read-only runner pwd remains partial capability"); const runnerSecondTurn = await handleCodeAgentChat( { @@ -429,9 +429,10 @@ async function runLocalContractSmoke() { logOk("M3 Skill CLI routes through HWLAB API only"); const completedHttp200Readiness = classifyCodeAgentChatReadiness(completedLivePayload, { realDevLive: true, httpStatus: 200 }); - assert.equal(completedHttp200Readiness.status, "pass"); - assert.equal(completedHttp200Readiness.devLiveReplyPass, true); - logOk("HTTP 200 completion-looking payload can pass"); + assert.equal(completedHttp200Readiness.status, "blocked"); + assert.equal(completedHttp200Readiness.devLiveReplyPass, false); + assert.equal(completedHttp200Readiness.blocker, "untrusted-completion"); + logOk("HTTP 200 OpenAI fallback completion is not full Code Agent pass"); for (const status of [400, 401, 403, 429, 500, 502, 503, 504]) { const readiness = classifyCodeAgentChatReadiness(completedLivePayload, { realDevLive: true, httpStatus: status }); diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 87a7e88b..4d2a74ca 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -469,8 +469,9 @@ test("Code Agent readiness classifier blocks completed payloads over any non-2xx }; const accepted = classifyCodeAgentChatReadiness(completedPayload, { realDevLive: true, httpStatus: 200 }); - assert.equal(accepted.status, "pass"); - assert.equal(accepted.devLiveReplyPass, true); + assert.equal(accepted.status, "blocked"); + assert.equal(accepted.devLiveReplyPass, false); + assert.equal(accepted.blocker, "untrusted-completion"); for (const httpStatus of [400, 401, 403, 429, 500, 502, 503, 504]) { const blocked = classifyCodeAgentChatReadiness(completedPayload, { realDevLive: true, httpStatus }); @@ -485,12 +486,16 @@ test("Code Agent readiness classifier blocks completed payloads over any non-2xx test("Code Agent browser classifier accepts only completed HTTP success plus completed UI and real provider evidence", () => { const summary = sanitizeAgentChatBody({ status: "completed", - provider: "openai-responses", + provider: "codex-stdio", model: "gpt-5.5", - backend: "hwlab-cloud-api/openai-responses", + backend: "hwlab-cloud-api/codex-mcp-stdio", traceId: "trc_completed", reply: { content: "ok" + }, + longLivedSessionGate: { + status: "pass", + pass: true } }, { httpStatus: 200 }); diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 4a4ac0e2..7afa175b 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -1,5 +1,6 @@ -const trustedLiveProviders = new Set(["openai-responses", "codex-cli", "codex-readonly-runner"]); -const trustedRunnerProviders = new Set(["codex-readonly-runner"]); +const trustedLiveProviders = new Set(["openai-responses", "codex-cli", "codex-readonly-runner", "codex-stdio"]); +const trustedRunnerProviders = new Set(["codex-stdio"]); +const partialRunnerProviders = new Set(["codex-readonly-runner"]); const readonlySessionCapabilityLevels = new Set(["read-only-tools", "read-only-session-tools"]); const untrustedProviderPattern = /\b(?:echo|mock|fixture|stub|sample)\b/iu; const blockedCodeAgentUiLabels = new Set([ @@ -115,27 +116,38 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { const missing = []; if (payload?.status !== "completed") missing.push("completed status"); if (!trustedRunnerProviders.has(provider)) missing.push("runner provider"); - if (runnerKind !== "hwlab-readonly-runner") missing.push("runner.kind"); - if (!readonlySessionCapabilityLevels.has(capabilityLevel)) missing.push("capabilityLevel=read-only-session-tools"); + if (runnerKind !== "codex-mcp-stdio-runner") missing.push("runner.kind=codex-mcp-stdio-runner"); + if (capabilityLevel !== "long-lived-codex-stdio-session") missing.push("capabilityLevel=long-lived-codex-stdio-session"); if (!workspace) missing.push("workspace"); - if (sandbox !== "read-only") missing.push("sandbox=read-only"); - if (sessionMode !== "controlled-readonly-session-registry") missing.push("sessionMode=controlled-readonly-session-registry"); - if (implementationType !== "controlled-readonly-session-registry") missing.push("implementationType=controlled-readonly-session-registry"); + if (sandbox !== "workspace-write") missing.push("sandbox=workspace-write"); + if (sessionMode !== "codex-mcp-stdio-long-lived") missing.push("sessionMode=codex-mcp-stdio-long-lived"); + if (implementationType !== "repo-owned-codex-mcp-stdio-session") missing.push("implementationType=repo-owned-codex-mcp-stdio-session"); if (!session) missing.push("session"); if (session && !["idle", "ready", "busy"].includes(session.status)) missing.push("session.status active/idle"); if (session && typeof session.idleTimeoutMs !== "number") missing.push("session.idleTimeoutMs"); if (session && !session.lastTraceId) missing.push("session.lastTraceId"); + if (session && session.longLivedSession !== true) missing.push("session.longLivedSession=true"); + if (session && session.codexStdio !== true) missing.push("session.codexStdio=true"); if (!sessionReuse) missing.push("sessionReuse"); if (toolCalls.length === 0) missing.push("toolCalls"); if (!runnerTrace) missing.push("runnerTrace"); if (!skills) missing.push("skills"); - if (!runnerLimitations.includes("not-codex-stdio")) missing.push("runnerLimitations.not-codex-stdio"); - if (payload?.runner?.codexStdio !== false) missing.push("runner.codexStdio=false"); - if (payload?.runner?.writeCapable !== false) missing.push("runner.writeCapable=false"); - if (payload?.runner?.durableSession !== false) missing.push("runner.durableSession=false"); + if (payload?.runner?.codexStdio !== true) missing.push("runner.codexStdio=true"); + if (payload?.runner?.writeCapable !== true) missing.push("runner.writeCapable=true"); + if (payload?.runner?.durableSession !== true) missing.push("runner.durableSession=true"); if (!longLivedSessionGate) missing.push("longLivedSessionGate"); + if (longLivedSessionGate?.status !== "pass" || longLivedSessionGate?.pass !== true) missing.push("longLivedSessionGate=pass"); if (missing.length > 0) { + if (partialRunnerProviders.has(provider) && runnerKind === "hwlab-readonly-runner") { + return { + status: "blocked", + level: "BLOCKED/controlled-readonly-session-registry", + blocker: "controlled-readonly-not-long-lived", + capabilityPass: false, + reason: "controlled-readonly-session-registry can expose bounded read-only tools, but it cannot pass the long-lived Codex stdio capability gate." + }; + } return { status: "blocked", level: "BLOCKED/runner-capability", @@ -145,16 +157,6 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { }; } - if (longLivedSessionGate?.status === "pass") { - return { - status: "blocked", - level: "BLOCKED/runner-gate", - blocker: "unexpected-long-lived-green", - capabilityPass: false, - reason: "read-only session registry responses must not claim the full long-lived Codex stdio gate." - }; - } - if (toolCalls.some((tool) => tool.status !== "completed")) { return { status: "blocked", @@ -167,11 +169,11 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { return { status: "pass", - level: "#317 read-only session registry partial pass", + level: "#275 long-lived Codex stdio session skeleton pass", blocker: null, capabilityPass: true, - longLivedSession: false, - reason: "completed read-only session-registry response includes session, workspace, sandbox, toolCalls, skills, runnerTrace, and a blocked long-lived Codex stdio gate" + longLivedSession: true, + reason: "completed repo-owned Codex stdio response includes reusable session, workspace-write sandbox, toolCalls, skills, runnerTrace, and a passed long-lived session gate" }; } @@ -538,6 +540,15 @@ export function inspectRealCompletionEvidence(payload) { if (provider === "codex-readonly-runner" && payload?.longLivedSessionGate?.status === "pass") { return { ok: false, reason: "read-only runner cannot claim long-lived session gate pass" }; } + if (provider === "codex-readonly-runner") { + return { ok: false, reason: "controlled read-only runner is partial capability only and cannot be reported as full Code Agent completion" }; + } + if (provider === "openai-responses" && payload?.longLivedSessionGate?.status !== "pass") { + return { ok: false, reason: "OpenAI Responses fallback is text-chat-only and cannot be reported as full Code Agent completion" }; + } + if (provider === "codex-stdio" && payload?.longLivedSessionGate?.status !== "pass") { + return { ok: false, reason: "codex-stdio completion is missing long-lived session gate pass" }; + } if (untrustedProviderPattern.test(`${provider} ${backend}`)) { return { ok: false, reason: `provider/backend looks non-real: ${payload.provider}/${payload.backend}` }; } @@ -675,6 +686,7 @@ function summarizeSession(value) { turn: typeof value.turn === "number" ? value.turn : null, longLivedSession: value.longLivedSession === true, codexStdio: value.codexStdio === true, + writeCapable: value.writeCapable === true, secretMaterialStored: value.secretMaterialStored === true }; } @@ -687,6 +699,20 @@ function summarizeCodexStdioFeasibility(value) { currentImplementation: stringOrNull(value.currentImplementation), implementationRequired: stringOrNull(value.implementationRequired), binaryOnPath: value.binaryOnPath === true, + workspace: stringOrNull(value.workspace), + sandbox: stringOrNull(value.sandbox), + ready: value.ready === true, + sessionLifecycle: value.sessionLifecycle && typeof value.sessionLifecycle === "object" + ? { + create: value.sessionLifecycle.create === true, + reuse: value.sessionLifecycle.reuse === true, + cancel: value.sessionLifecycle.cancel === true, + reap: value.sessionLifecycle.reap === true, + idleTimeout: value.sessionLifecycle.idleTimeout === true, + traceCapture: value.sessionLifecycle.traceCapture === true, + idleTimeoutMs: typeof value.sessionLifecycle.idleTimeoutMs === "number" ? value.sessionLifecycle.idleTimeoutMs : null + } + : null, blockers: Array.isArray(value.blockers) ? value.blockers.map((blocker) => ({ code: stringOrNull(blocker.code), diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index 9af871f4..557a331f 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -377,6 +377,10 @@ function validateCloudApiCodeAgentSource(ctx, env) { "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL", "cloud API Code Agent DEV egress/proxy base URL" ); + expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED, "1", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED", "cloud API Codex stdio adapter enabled flag"); + expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR, "repo-owned", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", "cloud API Codex stdio supervisor mode"); + expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, "/workspace/hwlab", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE", "cloud API Codex stdio workspace mount contract"); + expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "workspace-write", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_SANDBOX", "cloud API Codex stdio sandbox contract"); expect( ctx, env.HWLAB_CODE_AGENT_OPENAI_BASE_URL !== contract.egress.forbiddenDirectBaseUrl, @@ -532,6 +536,10 @@ function validateCloudApiCodeAgentArtifacts(ctx, workloads) { "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL", "cloud API workload Code Agent DEV egress/proxy base URL" ); + expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED")?.value, "1", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED", "cloud API workload Codex stdio adapter enabled flag"); + expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR")?.value, "repo-owned", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", "cloud API workload Codex stdio supervisor mode"); + expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_WORKSPACE")?.value, "/workspace/hwlab", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE", "cloud API workload Codex stdio workspace mount contract"); + expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_SANDBOX")?.value, "workspace-write", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_SANDBOX", "cloud API workload Codex stdio sandbox contract"); expect( ctx, env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value !== contract.egress.forbiddenDirectBaseUrl, diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 7159f2a9..a0b41419 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -211,7 +211,10 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([ "message", "providerTrace", "openai-responses", + "codex-stdio", "codex-cli", + "codex-mcp-stdio-long-lived", + "repo-owned-codex-mcp-stdio-session", "controlled-readonly-session-registry", "not-codex-stdio", "not-write-capable", @@ -491,7 +494,7 @@ function runStaticSmoke() { evidence: ["DEFAULT_API_TIMEOUT_MS=4500 for light probes", `DEFAULT_CODE_AGENT_TIMEOUT_MS=${codeAgentLongTimeoutMs}`, "sendAgentMessage passes timeoutMs"] }); - addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider blockers without exposing credential internals and only real completed replies can become DEV-LIVE.", { + addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider/stdio blockers without exposing credential internals and only long-lived Codex stdio replies can become full Code Agent completion.", { blocker: "observability_blocker", evidence: ["服务受阻 or legacy BLOCKED 凭证缺口", "provider_unavailable classifier", "completed -> dev-live guard", "default workspace hides credential internals"] }); @@ -506,7 +509,7 @@ function runStaticSmoke() { evidence: ["rail-button min-width/min-height", "state-tag wrapping", "side-tab stable labels", "mobile message evidence column"] }); - addCheck(checks, blockers, "code-agent-completed-evidence-visible", hasCodeAgentCompletedEvidenceVisibility(files), "Completed Code Agent replies expose provider/model/trace/conversation evidence and reject echo/mock/stub completions.", { + addCheck(checks, blockers, "code-agent-completed-evidence-visible", hasCodeAgentCompletedEvidenceVisibility(files), "Completed full Code Agent replies expose Codex stdio provider/model/trace/conversation/session evidence and reject echo/mock/stub, OpenAI fallback, and read-only runner completions.", { blocker: "observability_blocker", evidence: requiredCodeAgentEvidenceTerms }); @@ -2228,7 +2231,7 @@ function hasCodeAgentChatContract({ html, app }) { ); } -function hasCodeAgentReadinessVisibility({ html, app }) { +function hasCodeAgentReadinessVisibility({ html, app, liveStatus = "" }) { const source = `${html}\n${app}`; const userFacingBodies = [ functionBody(app, "codeAgentStatusMessage"), @@ -2256,6 +2259,9 @@ function hasCodeAgentReadinessVisibility({ html, app }) { /isRealCompletedChatMessage/u.test(app) && /isRealCompletedChatResult/u.test(app) && /hasRealCodeAgentEvidence/u.test(app) && + /codeAgentBlockerDetail/u.test(app) && + /codex_stdio_blocked_readonly_available/u.test(liveStatus) && + /codex_stdio_supervisor_disabled/u.test(app) && !/status === "failed" \? "dev-live"/u.test(app) && !/tone:\s*state\.conversationId\s*\?\s*"dev-live"/u.test(app) && !/provider_unavailable[\s\S]{0,120}tone-[\w-]*dev-live/iu.test(source) && @@ -2297,14 +2303,18 @@ function hasCodeAgentCompletedEvidenceVisibility({ app }) { /isTrustedCodeAgentProvider\(value\?\.provider\)/u.test(realEvidenceBody) && /isCodexRunnerCapableEvidence\(value\)/u.test(realEvidenceBody) && /CODEX_RUNNER_CAPABLE_PROVIDERS/u.test(app) && - /controlled-readonly-session-registry/u.test(runnerEvidenceBody) && - /read-only-session-tools/u.test(runnerEvidenceBody) && + /CODEX_READONLY_PARTIAL_PROVIDERS/u.test(app) && + /codex-mcp-stdio-long-lived/u.test(runnerEvidenceBody) && + /repo-owned-codex-mcp-stdio-session/u.test(runnerEvidenceBody) && + /long-lived-codex-stdio-session/u.test(runnerEvidenceBody) && + /isReadOnlyRunnerPartialEvidence/u.test(app) && + /isTextFallbackChatResult/u.test(app) && /value\?\.session\?\.status === "idle"/u.test(runnerEvidenceBody) && - /value\?\.longLivedSessionGate\?\.status === "blocked"/u.test(runnerEvidenceBody) && - /not-codex-stdio/u.test(runnerEvidenceBody) && - /value\?\.runner\?\.codexStdio === false/u.test(runnerEvidenceBody) && - /value\?\.runner\?\.writeCapable === false/u.test(runnerEvidenceBody) && - /value\?\.runner\?\.durableSession === false/u.test(runnerEvidenceBody) && + /value\?\.longLivedSessionGate\?\.status === "pass"/u.test(runnerEvidenceBody) && + /value\?\.session\?\.codexStdio === true/u.test(runnerEvidenceBody) && + /value\?\.runner\?\.codexStdio === true/u.test(runnerEvidenceBody) && + /value\?\.runner\?\.writeCapable === true/u.test(runnerEvidenceBody) && + /value\?\.runner\?\.durableSession === true/u.test(runnerEvidenceBody) && /TRUSTED_CODE_AGENT_PROVIDERS/u.test(app) && /UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN/u.test(realEvidenceBody) && /value\?\.conversationId \|\| value\?\.sessionId/u.test(realEvidenceBody) && diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index 4d0408cb..e141c3b8 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -203,6 +203,10 @@ assert.equal( DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.defaultBaseUrl, "cloud-api Code Agent OpenAI base URL must use DEV egress/proxy" ); +assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED, "1", "cloud-api Codex stdio adapter enabled flag"); +assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR, "repo-owned", "cloud-api Codex stdio supervisor mode"); +assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, "/workspace/hwlab", "cloud-api Codex stdio workspace mount contract"); +assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "workspace-write", "cloud-api Codex stdio sandbox contract"); assert.notEqual( cloudApi.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL, DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.forbiddenDirectBaseUrl, @@ -256,6 +260,10 @@ function assertCodeAgentProviderWorkloadContract(env) { contract.egress.defaultBaseUrl, "cloud-api workload Code Agent OpenAI base URL must use DEV egress/proxy" ); + assert.equal(env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED?.value, "1", "cloud-api workload Codex stdio adapter enabled flag"); + assert.equal(env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR?.value, "repo-owned", "cloud-api workload Codex stdio supervisor mode"); + assert.equal(env.HWLAB_CODE_AGENT_CODEX_WORKSPACE?.value, "/workspace/hwlab", "cloud-api workload Codex stdio workspace mount contract"); + assert.equal(env.HWLAB_CODE_AGENT_CODEX_SANDBOX?.value, "workspace-write", "cloud-api workload Codex stdio sandbox contract"); assert.notEqual( env.HWLAB_CODE_AGENT_OPENAI_BASE_URL?.value, contract.egress.forbiddenDirectBaseUrl, diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 8d8a8e67..45ca13ba 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -54,8 +54,15 @@ const M3_TRUSTED_ROUTE = Object.freeze({ const LIVE_M3_ID_FIELDS = Object.freeze(["operationId", "traceId", "auditId", "evidenceId"]); const LIVE_M3_PASS_STATUSES = Object.freeze(["pass", "passed", "verified", "succeeded", "trusted", "completed"]); const NON_LIVE_ID_VALUES = Object.freeze(["", "n/a", "none", "null", "undefined", "not_observed", "not-observed"]); -const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["openai-responses", "codex-cli", "codex-readonly-runner"]); -const CODEX_RUNNER_CAPABLE_PROVIDERS = Object.freeze(["codex-readonly-runner"]); +const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["openai-responses", "codex-cli", "codex-readonly-runner", "codex-stdio"]); +const CODEX_RUNNER_CAPABLE_PROVIDERS = Object.freeze(["codex-stdio"]); +const CODEX_READONLY_PARTIAL_PROVIDERS = Object.freeze(["codex-readonly-runner"]); +const CODEX_STDIO_BLOCKER_MARKERS = Object.freeze([ + "codex_stdio_supervisor_disabled", + "not-codex-stdio", + "not-write-capable", + "not-durable-session" +]); const UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN = /\b(?:echo|mock|fixture|stub|sample)\b/iu; const LAYOUT_STORAGE_KEY = "hwlab.workbench.layout.v1"; const EXPLORER_DEFAULT_WIDTH = 292; @@ -2100,6 +2107,10 @@ function sourceFixtureTitle(result) { return `Code Agent SOURCE 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; } +function textFallbackTitle(result) { + return `Code Agent 文本 fallback 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; +} + function classifyCodeAgentCompletion(result) { if (isRealCompletedChatResult(result)) { return { @@ -2117,6 +2128,14 @@ function classifyCodeAgentCompletion(result) { title: sourceFixtureTitle(result) }; } + if (isTextFallbackChatResult(result)) { + return { + status: "source", + replied: true, + sourceKind: "TEXT-FALLBACK", + title: textFallbackTitle(result) + }; + } return { status: "failed", replied: false, @@ -2535,11 +2554,19 @@ function sanitizeCodeAgentAvailability(availability) { } function codeAgentStatusMessage(availability) { + if (availability?.longLivedSessionGate?.status === "pass" && availability?.codexStdioFeasibility?.canStartLongLivedCodexStdio === true) { + return { + role: "system", + title: "Code Agent 状态:Codex stdio 长会话可用", + text: "当前通道可创建和复用 repo-owned Codex stdio session,并暴露 cancel、reap、idle timeout 与 trace capture;硬件控制仍只走 cloud-api/HWLAB API/skill CLI。", + status: "source" + }; + } if (availability?.runner?.ready === true) { return { role: "system", - title: "Code Agent 状态:受控只读 session registry 已接入", - text: "pwd、skills、ls、rg --files 和 cat 会走 controlled-readonly-session-registry;这是 not-codex-stdio、not-write-capable、not-durable-session 的安全增量,普通中文对话可走 OpenAI Responses fallback。", + title: "Code Agent 状态:只读 session registry 可用,Codex stdio 受阻", + text: `pwd、skills、ls、rg --files 和 cat 会走 controlled-readonly-session-registry;它不是完整 Code Agent。${codeAgentBlockerDetail(availability)}`, status: "source" }; } @@ -2594,6 +2621,9 @@ function codeAgentControlSummary(availability) { if (availability?.status === "blocked") { return codeAgentBlockedSummary(availability); } + if (availability?.partialReady === true || availability?.runner?.ready === true) { + return `输入区会调用受控接口;当前只有只读 registry 或 text fallback,不能标成完整 Code Agent。${codeAgentBlockerDetail(availability)}`; + } return "输入区会调用受控 Code Agent 接口;只有真实完成回复才显示为完成,不能因为只有会话编号就当成实况完成。"; } @@ -2602,7 +2632,18 @@ function codeAgentBlockedSummary(availability) { if (providerStatus) { return `真实后端已接入,但当前上游响应 HTTP ${providerStatus};工作台保持只读和可重试,不会冒充真实 Code Agent 回复。`; } - return "真实后端已接入,但当前 Code Agent 服务暂不可用;工作台保持只读和可重试,不会冒充真实回复。"; + const blocker = codeAgentBlockerDetail(availability); + return `真实后端已接入,但 Code Agent 服务暂不可用;完整 Codex stdio Code Agent 仍受阻,工作台保持只读和可重试,不会冒充真实回复。${blocker}`; +} + +function codeAgentBlockerDetail(availability) { + const blockers = Array.isArray(availability?.blockers) + ? availability.blockers.map((blocker) => blocker?.code).filter(Boolean) + : Array.isArray(availability?.codexStdioFeasibility?.blockers) + ? availability.codexStdioFeasibility.blockers.map((blocker) => blocker?.code).filter(Boolean) + : []; + if (blockers.length === 0 && availability?.reason) blockers.push(availability.reason); + return blockers.length > 0 ? ` blocker=${blockers.slice(0, 3).join(",")}。` : ""; } function untrustedCompletionMessage(result) { @@ -2611,8 +2652,11 @@ function untrustedCompletionMessage(result) { } function codeAgentAvailabilitySummary() { + if (state.codeAgentAvailability?.longLivedSessionGate?.status === "pass" && state.codeAgentAvailability?.codexStdioFeasibility?.canStartLongLivedCodexStdio === true) { + return "同源 API 可响应;Codex stdio 长会话 gate 已通过,支持真实 session 复用和 trace capture。"; + } if (state.codeAgentAvailability?.runner?.ready === true) { - return "同源 API 可响应;pwd/skills/ls/rg/cat 可走受控只读 session registry,但它不是 Codex stdio、不可写、非持久 session。"; + return `同源 API 可响应;pwd/skills/ls/rg/cat 可走受控只读 session registry,但它不是 Codex stdio、不可写、非持久 session。${codeAgentBlockerDetail(state.codeAgentAvailability)}`; } if (state.codeAgentAvailability?.status === "blocked") { return "同源 API 可响应;Code Agent 服务暂不可用,工作台保持只读和可重试。"; @@ -2643,6 +2687,15 @@ function isSourceFixtureChatResult(result) { return result?.status === "completed" && assistantReply.length > 0 && isSourceFixtureCompletion(result); } +function isTextFallbackChatResult(result) { + const assistantReply = typeof result?.reply?.content === "string" ? result.reply.content.trim() : ""; + return result?.status === "completed" && + assistantReply.length > 0 && + result?.provider === "openai-responses" && + (result?.capabilityLevel === "text-chat-only" || result?.runner?.kind === "openai-responses-fallback") && + result?.longLivedSessionGate?.status !== "pass"; +} + function isRealCompletedChatMessage(message) { return message?.status === "completed" && !message.error && hasRealCodeAgentEvidence(message); } @@ -2655,6 +2708,9 @@ function hasRealCodeAgentEvidence(value) { if (isCodexRunnerCapableEvidence(value)) { return true; } + if (isReadOnlyRunnerPartialEvidence(value) || isTextFallbackChatResult(value)) { + return false; + } return ( isTrustedCodeAgentProvider(value?.provider) && Boolean(value?.model) && @@ -2668,26 +2724,40 @@ function hasRealCodeAgentEvidence(value) { function isCodexRunnerCapableEvidence(value) { return ( CODEX_RUNNER_CAPABLE_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase()) && - value?.runner?.kind === "hwlab-readonly-runner" && - (value?.capabilityLevel === "read-only-session-tools" || value?.capabilityLevel === "read-only-tools") && + value?.runner?.kind === "codex-mcp-stdio-runner" && + value?.capabilityLevel === "long-lived-codex-stdio-session" && value?.session?.status === "idle" && typeof value?.session?.idleTimeoutMs === "number" && Boolean(value?.session?.lastTraceId) && + value?.sessionMode === "codex-mcp-stdio-long-lived" && + value?.implementationType === "repo-owned-codex-mcp-stdio-session" && + Boolean(value?.sessionReuse) && + value?.session?.longLivedSession === true && + value?.session?.codexStdio === true && + value?.runner?.codexStdio === true && + value?.runner?.writeCapable === true && + value?.runner?.durableSession === true && + Boolean(value?.workspace || value?.runner?.workspace) && + (value?.sandbox || value?.runner?.sandbox) === "workspace-write" && + Array.isArray(value?.toolCalls) && + value.toolCalls.some((tool) => tool?.status === "completed") && + value?.longLivedSessionGate?.status === "pass" && + Boolean(value?.runnerTrace) && + Boolean(value?.skills) + ); +} + +function isReadOnlyRunnerPartialEvidence(value) { + return ( + CODEX_READONLY_PARTIAL_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase()) && + value?.runner?.kind === "hwlab-readonly-runner" && + (value?.capabilityLevel === "read-only-session-tools" || value?.capabilityLevel === "read-only-tools") && value?.sessionMode === "controlled-readonly-session-registry" && value?.implementationType === "controlled-readonly-session-registry" && - Boolean(value?.sessionReuse) && value?.runner?.codexStdio === false && value?.runner?.writeCapable === false && value?.runner?.durableSession === false && - Array.isArray(value?.runnerLimitations) && - value.runnerLimitations.includes("not-codex-stdio") && - Boolean(value?.workspace || value?.runner?.workspace) && - (value?.sandbox || value?.runner?.sandbox) === "read-only" && - Array.isArray(value?.toolCalls) && - value.toolCalls.some((tool) => tool?.status === "completed") && - value?.longLivedSessionGate?.status === "blocked" && - Boolean(value?.runnerTrace) && - Boolean(value?.skills) + value?.longLivedSessionGate?.status === "blocked" ); } diff --git a/web/hwlab-cloud-web/live-status.mjs b/web/hwlab-cloud-web/live-status.mjs index 85d6cef7..b5962036 100644 --- a/web/hwlab-cloud-web/live-status.mjs +++ b/web/hwlab-cloud-web/live-status.mjs @@ -22,6 +22,9 @@ const READ_ONLY_REASON_CODES = Object.freeze([ "not-durable-session", "text-chat-only", "fallback:text-chat-only", + "codex_stdio_blocked_readonly_available", + "controlled_readonly_not_long_lived_stdio", + "codex_stdio_supervisor_disabled", "security_blocked" ]); @@ -150,6 +153,16 @@ function classifyApiProbe(probe, context) { const payload = probe.data; const serviceId = payload?.serviceId ?? context.serviceId; + if (context.apiPath === "/v1" && payload?.codeAgent) { + const codeAgentProbe = classifyCodeAgentProbe({ restIndex: probe }); + if (codeAgentProbe?.kind === "readonly") { + return { + ...codeAgentProbe, + apiPath: context.apiPath, + rawStatus: rawStatusFrom(payload) + }; + } + } const durableReason = durableRuntimeReason(payload); if (durableReason) { return readOnlyProbe({ @@ -254,15 +267,17 @@ function classifyCodeAgentProbe(live = {}) { const backend = availability.backend ?? "not_observed"; const capability = availability.capabilityLevel ?? availability.runner?.capabilityLevel; const providerStatus = availability.error?.providerStatus ?? availability.providerStatus; - if (availability.status === "blocked" || availability.ready === false) { - return errorProbe({ + const gate = availability.longLivedSessionGate; + const stdio = availability.codexStdio ?? availability.codexStdioFeasibility; + if (gate?.status === "pass" && stdio?.canStartLongLivedCodexStdio === true && availability.ready === true) { + return passProbe({ serviceId, apiPath, - reasonCode: providerStatus ? `provider_http_${providerStatus}` : availability.reason ?? availability.blocker ?? "code_agent_blocked", - reason: availability.summary ?? `provider=${provider}; backend=${backend}; capability=${capability ?? "unknown"}`, + reasonCode: "codex_stdio_ready", + reason: `provider=codex-stdio; backend=${availability.codexStdio?.backend ?? backend}; capability=long-lived-codex-stdio-session`, traceId: traceIdFrom(availability), evidenceSummary: evidenceSummaryFrom(availability), - meta: { provider, backend, capability } + meta: { provider: "codex-stdio", backend: availability.codexStdio?.backend ?? backend, capability: "long-lived-codex-stdio-session" } }); } if (capability === "text-chat-only" || availability.runner?.kind === "openai-responses-fallback") { @@ -276,6 +291,27 @@ function classifyCodeAgentProbe(live = {}) { meta: { provider, backend, capability } }); } + if (availability.agentKind === "controlled-readonly-session-registry" || availability.partialReady === true) { + return readOnlyProbe({ + serviceId, + apiPath, + reasonCode: "codex_stdio_blocked_readonly_available", + reason: `${availability.summary ?? "controlled-readonly-session-registry available; long-lived Codex stdio blocked"}; blockers=${blockerCodes(availability).join(",") || "not_observed"}`, + traceId: traceIdFrom(availability), + evidenceSummary: evidenceSummaryFrom(availability), + meta: { provider, backend, capability } + }); + } + if (availability.status === "blocked" || availability.ready === false) { + return errorProbe({ + serviceId, + apiPath, + reasonCode: providerStatus ? `provider_http_${providerStatus}` : availability.reason ?? availability.blocker ?? "code_agent_blocked", + reason: availability.summary ?? `provider=${provider}; backend=${backend}; capability=${capability ?? "unknown"}`, + traceId: traceIdFrom(availability), + meta: { provider, backend, capability } + }); + } if (availability.runner?.ready === true && availability.runner?.writeCapable === false) { return readOnlyProbe({ serviceId, @@ -309,6 +345,12 @@ function classifyCodeAgentProbe(live = {}) { }); } +function blockerCodes(availability) { + if (Array.isArray(availability?.blockerCodes)) return availability.blockerCodes.filter(Boolean); + if (Array.isArray(availability?.blockers)) return availability.blockers.map((blocker) => blocker?.code).filter(Boolean); + return []; +} + function classifyM3ControlProbe(live = {}) { const probe = live.m3Control; const context = { diff --git a/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs b/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs index d5f52a81..524052fb 100644 --- a/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs +++ b/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs @@ -45,6 +45,66 @@ const okRest = Object.freeze({ } }); +const codexStdioRest = Object.freeze({ + ok: true, + status: 200, + data: { + serviceId: "hwlab-cloud-api", + status: "ok", + ready: true, + codeAgent: { + endpoint: "POST /v1/agent/chat", + status: "codex-stdio-feasible", + ready: true, + provider: "codex-stdio", + backend: "hwlab-cloud-api/codex-mcp-stdio", + capabilityLevel: "long-lived-codex-stdio-session", + agentKind: "codex-stdio-feasible", + codexStdioFeasibility: { + canStartLongLivedCodexStdio: true + }, + longLivedSessionGate: { + status: "pass", + pass: true + } + } + } +}); + +const readonlyOnlyRest = Object.freeze({ + ok: true, + status: 200, + data: { + serviceId: "hwlab-cloud-api", + status: "degraded", + ready: false, + codeAgent: { + endpoint: "POST /v1/agent/chat", + status: "partial", + ready: false, + partialReady: true, + agentKind: "controlled-readonly-session-registry", + provider: "openai-responses", + backend: "hwlab-cloud-api/openai-responses", + capabilityLevel: "read-only-session-tools", + runner: { + kind: "hwlab-readonly-runner", + ready: true, + writeCapable: false + }, + blockerCodes: ["codex_stdio_supervisor_disabled", "provider_token_boundary"], + codexStdioFeasibility: { + status: "blocked", + blockers: [{ code: "codex_stdio_supervisor_disabled" }] + }, + longLivedSessionGate: { + status: "blocked", + pass: false + } + } + } +}); + const okM3Control = Object.freeze({ ok: true, status: 200, @@ -72,7 +132,7 @@ const okM3Status = Object.freeze({ test("classifies fully ready workbench runtime as API 正常", () => { const status = classifyWorkbenchLiveStatus({ healthLive: okApi, - restIndex: okRest, + restIndex: codexStdioRest, health: okApi, adapter: okApi, m3Control: okM3Control, @@ -85,6 +145,22 @@ test("classifies fully ready workbench runtime as API 正常", () => { assert.equal(status.internalRawStatuses.includes("degraded"), false); }); +test("classifies controlled readonly Code Agent as read-only, not full pass", () => { + const status = classifyWorkbenchLiveStatus({ + healthLive: okApi, + restIndex: readonlyOnlyRest, + health: okApi, + adapter: okApi, + m3Control: okM3Control, + m3Status: okM3Status + }); + + assert.equal(status.kind, "readonly"); + assert.equal(status.label, "只读模式"); + assert.equal(status.reasonCode, "codex_stdio_blocked_readonly_available"); + assert.match(status.detail, /controlled-readonly-session-registry/u); +}); + test("classifies concrete API failure with service path and HTTP status", () => { const status = classifyWorkbenchLiveStatus({ healthLive: {