diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 5a794ef8..b9b5426e 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -13,6 +13,7 @@ import { } from "./code-agent-contract.mjs"; import { CODEX_STDIO_BACKEND, + CODEX_STDIO_CAPABILITY_LEVEL, CODEX_STDIO_IMPLEMENTATION_TYPE, CODEX_STDIO_PROVIDER, CODEX_STDIO_RUNNER_KIND, @@ -78,6 +79,19 @@ const READONLY_LIMITATION_FLAGS = Object.freeze([ "not-write-capable", "process-local-session-registry" ]); +const CODEX_STDIO_LIMITATION_FLAGS = Object.freeze([ + "hardware-control-via-cloud-api-only", + "no-direct-gateway-link", + "no-direct-box-simu-link", + "no-direct-patch-panel-link", + "secret-values-redacted" +]); +const OPENAI_FALLBACK_LIMITATION_FLAGS = Object.freeze([ + "openai-responses-fallback", + "text-chat-only", + "not-workspace-tools", + "not-session-runner" +]); const SKIPPED_READONLY_DIRS = new Set([ ".git", ".hg", @@ -271,12 +285,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { sessionMode: providerResult.sessionMode ?? "provider-text-request", sessionReuse: providerResult.sessionReuse ?? null, implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND, - runnerLimitations: providerResult.runnerLimitations ?? [ - "text-chat-only", - "not-codex-stdio", - "not-workspace-tools", - "not-session-runner" - ], + runnerLimitations: providerResult.runnerLimitations ?? [...OPENAI_FALLBACK_LIMITATION_FLAGS], codexStdioFeasibility: providerResult.codexStdioFeasibility ?? await inspectCodexStdioFeasibility(options.env ?? process.env, options), longLivedSessionGate: providerLongLivedGate, toolCalls: Array.isArray(providerResult.toolCalls) ? providerResult.toolCalls : [], @@ -499,10 +508,15 @@ export async function describeCodeAgentAvailability(env = process.env, options = sessionRegistry }); const codexStdio = await inspectCodexStdioFeasibility(env, options); + const codexStdioReady = codexStdioLongLivedReady(codexStdio); + const codexStdioActive = providerPlan.mode !== "openai" && codexStdioReady; + const activeRunnerAvailability = codexStdioActive + ? codexStdioRunnerAvailability(codexStdio) + : runnerAvailability; const blocked = providerPlan.mode === "openai" ? !providerContract.ready : providerPlan.mode === "codex-stdio" - ? !codexStdio.ready + ? !codexStdioReady : missingEnv.length > 0; const fallbackAvailability = { provider: providerPlan.provider, @@ -520,29 +534,29 @@ export async function describeCodeAgentAvailability(env = process.env, options = 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, + provider: codexStdioActive ? CODEX_STDIO_PROVIDER : runnerAvailability.provider, + runnerKind: codexStdioActive ? CODEX_STDIO_RUNNER_KIND : runnerAvailability.kind, + sessionMode: codexStdioActive ? CODEX_STDIO_SESSION_MODE : READONLY_SESSION_MODE, + implementationType: codexStdioActive ? CODEX_STDIO_IMPLEMENTATION_TYPE : READONLY_IMPLEMENTATION_TYPE, codexStdioFeasibility: codexStdio }); - const agentKind = codexStdio.ready + const agentKind = codexStdioActive ? "codex-stdio-feasible" : runnerAvailability.ready ? "controlled-readonly-session-registry" : blocked ? "openai-fallback-blocked" : "openai-fallback"; - const capabilityStatus = codexStdio.ready + const capabilityStatus = codexStdioActive ? "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 + const codeAgentReady = codexStdioActive; + const status = codexStdioActive ? "codex-stdio-feasible" : blocked && !runnerAvailability.ready ? "blocked" : "partial"; + const capabilityLevel = codexStdioActive ? "long-lived-codex-stdio-session" : runnerAvailability.ready ? READONLY_SESSION_CAPABILITY_LEVEL @@ -585,7 +599,7 @@ export async function describeCodeAgentAvailability(env = process.env, options = "error.missingEnv", "availability.status" ], - runner: runnerAvailability, + runner: activeRunnerAvailability, fallback: fallbackAvailability, codexStdio, status, @@ -601,10 +615,14 @@ export async function describeCodeAgentAvailability(env = process.env, options = egress: providerContract.egress, safety: providerContract.safety, capabilityLevel, - sessionRegistry: runnerAvailability.sessionRegistry, - sessionMode: runnerAvailability.sessionMode, - implementationType: runnerAvailability.implementationType, - runnerLimitations: runnerAvailability.runnerLimitations, + workspace: activeRunnerAvailability.workspace, + sandbox: activeRunnerAvailability.sandbox, + session: activeRunnerAvailability.sessionRegistry?.recentSessions?.[0] ?? null, + sessionStatus: activeRunnerAvailability.sessionRegistry?.recentSessions?.[0]?.status ?? null, + sessionRegistry: activeRunnerAvailability.sessionRegistry ?? runnerAvailability.sessionRegistry, + sessionMode: activeRunnerAvailability.sessionMode, + implementationType: activeRunnerAvailability.implementationType, + runnerLimitations: activeRunnerAvailability.runnerLimitations, codexStdioFeasibility: codexStdio, longLivedSessionGate: longLivedGate, blockers: [ @@ -632,6 +650,78 @@ function fallbackBlockerForPlan({ providerPlan, providerContract, codexStdio }) return "凭证缺口"; } +function codexStdioLongLivedReady(codexStdio = {}) { + return Boolean( + (codexStdio.ready === true || codexStdio.canStartLongLivedCodexStdio === true) && + codexStdio.commandProbe?.ready === true + ); +} + +function codexStdioRunnerAvailability(codexStdio = {}) { + const workspace = codexStdio.workspace ?? repoRoot; + const sandbox = codexStdio.sandbox ?? "workspace-write"; + return { + kind: CODEX_STDIO_RUNNER_KIND, + backend: CODEX_STDIO_BACKEND, + provider: CODEX_STDIO_PROVIDER, + workspace, + sandbox, + mode: CODEX_STDIO_SESSION_MODE, + sessionMode: CODEX_STDIO_SESSION_MODE, + session: CODEX_STDIO_SESSION_MODE, + status: "available", + ready: true, + capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + longLivedSession: true, + durableSession: true, + durable: true, + codexStdio: true, + writeCapable: true, + readOnly: false, + runnerLimitations: [...CODEX_STDIO_LIMITATION_FLAGS], + codexStdioFeasibility: codexStdio, + longLivedSessionGate: longLivedSessionGate({ + provider: CODEX_STDIO_PROVIDER, + runnerKind: CODEX_STDIO_RUNNER_KIND, + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + codexStdioFeasibility: codexStdio + }), + sessionRegistry: codexStdioSessionRegistrySummary(codexStdio), + safety: { + secretsRead: false, + secretValuesPrinted: false, + kubeconfigRead: false, + hardwareControlViaCloudApiOnly: true, + directGatewayCallsAllowed: false, + directBoxSimuCallsAllowed: false, + directPatchPanelCallsAllowed: false, + m3m4m5AcceptanceClaimsAllowed: false + } + }; +} + +function codexStdioSessionRegistrySummary(codexStdio = {}) { + return { + kind: "codex-stdio-session-registry", + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + status: "available", + sessionCount: codexStdio.sessionLifecycle?.activeSessions ?? codexStdio.lifecycleSupervisor?.activeSessions ?? null, + maxSessions: codexStdio.sessionLifecycle?.maxSessions ?? codexStdio.lifecycleSupervisor?.maxSessions ?? null, + idleTimeoutMs: codexStdio.sessionLifecycle?.idleTimeoutMs ?? codexStdio.lifecycleSupervisor?.idleTimeoutMs ?? null, + statuses: ["creating", "ready", "busy", "idle", "interrupted", "expired", "failed"], + sessionMode: CODEX_STDIO_SESSION_MODE, + recentSessions: Array.isArray(codexStdio.recentSessions) ? codexStdio.recentSessions : [], + longLivedCodexStdio: true, + longLivedSession: true, + durable: true, + codexStdio: true, + secretMaterialStored: false, + valuesRedacted: true + }; +} + 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 : "凭证缺口"; @@ -2844,12 +2934,7 @@ function openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, codexRunner: false, reason: "OpenAI Responses fallback is text chat only and cannot satisfy the Codex runner capability gate." }, - runnerLimitations: [ - "text-chat-only", - "not-codex-stdio", - "not-workspace-tools", - "not-session-runner" - ], + runnerLimitations: [...OPENAI_FALLBACK_LIMITATION_FLAGS], traceId }; } @@ -2899,12 +2984,7 @@ async function callOpenAiResponses({ providerPlan, message, conversationId, trac usage: null, capabilityLevel: "text-chat-only", implementationType: OPENAI_FALLBACK_RUNNER_KIND, - runnerLimitations: [ - "text-chat-only", - "not-codex-stdio", - "not-workspace-tools", - "not-session-runner" - ], + runnerLimitations: [...OPENAI_FALLBACK_LIMITATION_FLAGS], providerTrace: { fallbackUsed: true, fallbackKind: OPENAI_FALLBACK_RUNNER_KIND, diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index ba70c8c9..d4abb9a0 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -389,6 +389,13 @@ test("OpenAI fallback and codex one-shot do not pass the long-lived session gate assert.equal(fallback.status, "completed"); assert.equal(fallback.runner.kind, "openai-responses-fallback"); assert.equal(fallback.capabilityLevel, "text-chat-only"); + assert.deepEqual(fallback.runnerLimitations, [ + "openai-responses-fallback", + "text-chat-only", + "not-workspace-tools", + "not-session-runner" + ]); + assert.equal(fallback.runnerLimitations.includes("not-codex-stdio"), false); assert.equal(fallback.blocker.code, "text_chat_only_fallback"); assert.equal(fallback.blocker.layer, "provider"); assert.equal(fallback.blocker.retryable, false); @@ -1388,6 +1395,10 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with assert.equal(first.capabilityLevel, "long-lived-codex-stdio-session"); assert.equal(first.longLivedSessionGate.status, "pass"); assert.equal(first.longLivedSessionGate.pass, true); + assert.deepEqual(first.runnerLimitations, ["hardware-control-via-cloud-api-only", "secret-values-redacted"]); + assert.equal(first.runnerLimitations.includes("not-codex-stdio"), false); + assert.equal(first.runnerLimitations.includes("not-write-capable"), false); + assert.equal(first.runnerLimitations.includes("process-local-session-registry"), false); assert.equal(classifyCodexRunnerCapability(first, { httpStatus: 200 }).capabilityPass, true); const second = await handleCodeAgentChat( @@ -1404,8 +1415,13 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with ); assert.equal(second.status, "completed"); assert.equal(second.sessionId, first.sessionId); + assert.equal(second.conversationId, first.conversationId); assert.equal(second.sessionReuse.reused, true); assert.equal(second.sessionReuse.turn, 2); + assert.equal(second.runnerTrace.sessionId, first.sessionId); + assert.equal(second.runnerTrace.sessionReused, true); + assert.equal(second.workspace, first.workspace); + assert.equal(second.sandbox, first.sandbox); assert.equal(second.toolCalls[0].name, "codex-reply"); assert.equal(second.reply.content, "second stdio reply"); assert.equal(calls[0].name, "codex"); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index bd861b71..d89a97ad 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -1811,6 +1811,61 @@ test("OpenAI fallback text chat does not satisfy Codex stdio capability gate", a } }); +test("cloud api health does not pass long-lived session gate for explicit OpenAI fallback even if Codex stdio is feasible", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-openai-health-")); + const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-openai-health-codex-home-")); + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, + HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + OPENAI_API_KEY: "test-openai-key-material", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" + }, + codexStdioManager: { + describe() { + return codexStdioReadyFixture({ workspace, codexHome }); + }, + async probe() { + return codexStdioReadyFixture({ workspace, codexHome }); + }, + async chat(params = {}) { + return codexStdioChatFixture({ workspace, codexHome, params }); + }, + cancel() {}, + reapIdle() {} + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/health/live`); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.codeAgent.provider, "openai-responses"); + assert.equal(payload.codeAgent.mode, "openai"); + assert.equal(payload.codeAgent.ready, false); + assert.equal(payload.codeAgent.capabilityLevel, "read-only-session-tools"); + assert.equal(payload.codeAgent.runner.kind, "hwlab-readonly-runner"); + assert.equal(payload.codeAgent.runner.codexStdio, false); + assert.equal(payload.codeAgent.longLivedSessionGate.status, "blocked"); + assert.equal(payload.readiness.sessionRunner.status, "codex_stdio_ready"); + assert.equal(payload.readiness.codeAgent.ready, false); + assert.equal(payload.readiness.codeAgent.provider.provider, "openai-responses"); + assert.equal(payload.readiness.codeAgent.sessionRunner.codexStdio, true); + assert.equal(JSON.stringify(payload.codeAgent).includes("test-openai-key-material"), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await rm(workspace, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); + } +}); + test("cloud api /v1/agent/chat parse and params errors use structured blocker envelope", async () => { const server = createCloudApiServer({ env: { @@ -1934,6 +1989,83 @@ test("cloud api /v1/agent/chat runs Codex stdio pwd with session and workspace e } }); +test("cloud api health reports Codex stdio runner facts without readonly limitations", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-health-stdio-")); + const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-health-codex-home-")); + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + OPENAI_API_KEY: "test-openai-key-material", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" + }, + codexStdioManager: { + describe() { + return codexStdioReadyFixture({ workspace, codexHome }); + }, + async probe() { + return codexStdioReadyFixture({ workspace, codexHome }); + }, + async chat(params = {}) { + return codexStdioChatFixture({ workspace, codexHome, params }); + }, + cancel() {}, + reapIdle() {} + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/health/live`); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.codeAgent.status, "codex-stdio-feasible"); + assert.equal(payload.codeAgent.ready, true); + assert.equal(payload.codeAgent.provider, "codex-stdio"); + assert.equal(payload.codeAgent.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.codeAgent.runner.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(payload.codeAgent.runner.codexStdio, true); + assert.equal(payload.codeAgent.runner.writeCapable, true); + assert.equal(payload.codeAgent.runner.durableSession, true); + assert.equal(payload.codeAgent.workspace, workspace); + assert.equal(payload.codeAgent.sandbox, "workspace-write"); + assert.equal(payload.codeAgent.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(payload.codeAgent.sessionRegistry.kind, "codex-stdio-session-registry"); + assert.deepEqual(payload.codeAgent.sessionRegistry.statuses, ["creating", "ready", "busy", "idle", "interrupted", "expired", "failed"]); + assert.equal(payload.codeAgent.capabilityLevel, "long-lived-codex-stdio-session"); + assert.equal(payload.codeAgent.longLivedSessionGate.status, "pass"); + assert.equal(payload.readiness.sessionRunner.status, "codex_stdio_ready"); + assert.equal(payload.readiness.sessionRunner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.readiness.sessionRunner.codexStdio, true); + assert.equal(payload.readiness.sessionRunner.writeCapable, true); + assert.equal(payload.readiness.sessionRunner.capabilityLevel, "long-lived-codex-stdio-session"); + assert.equal(payload.readiness.codeAgent.sessionRunner.codexStdio, true); + assert.equal(payload.readiness.codeAgent.sessionRunner.writeCapable, true); + assert.deepEqual(payload.codeAgent.runnerLimitations, [ + "hardware-control-via-cloud-api-only", + "no-direct-gateway-link", + "no-direct-box-simu-link", + "no-direct-patch-panel-link", + "secret-values-redacted" + ]); + const serialized = JSON.stringify(payload.codeAgent); + assert.equal(serialized.includes("not-codex-stdio"), false); + assert.equal(serialized.includes("not-write-capable"), false); + assert.equal(serialized.includes("process-local-session-registry"), false); + assert.equal(serialized.includes("test-openai-key-material"), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await rm(workspace, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); + } +}); + test("cloud api /v1/agent/chat runs Codex stdio pwd through controlled command path without OpenAI fallback", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-pwd-")); const codexHome = path.join(workspace, "codex-home"); diff --git a/web/hwlab-cloud-web/code-agent-status.mjs b/web/hwlab-cloud-web/code-agent-status.mjs index 15c8db45..f9a220eb 100644 --- a/web/hwlab-cloud-web/code-agent-status.mjs +++ b/web/hwlab-cloud-web/code-agent-status.mjs @@ -276,10 +276,18 @@ function hasBlockingSkillCliSignal(payload) { function isLongLivedReady(payload) { return ( - payload.longLivedSessionGate?.status === "pass" || - payload.longLivedSessionGate?.pass === true || - payload.codexStdioFeasibility?.canStartLongLivedCodexStdio === true || - payload.capabilityLevel === "long-lived-codex-stdio-session" || + ( + payload.longLivedSessionGate?.status === "pass" || + payload.longLivedSessionGate?.pass === true || + payload.codexStdioFeasibility?.canStartLongLivedCodexStdio === true + ) && + ( + payload.provider === "codex-stdio" || + payload.runner?.kind === "codex-mcp-stdio-runner" || + payload.runner?.codexStdio === true || + payload.session?.codexStdio === true + ) && + payload.capabilityLevel === "long-lived-codex-stdio-session" && payload.sessionMode === "codex-mcp-stdio-long-lived" ) && payload.status !== "failed" && payload.status !== "blocked" && payload.ready !== false; } diff --git a/web/hwlab-cloud-web/code-agent-status.test.mjs b/web/hwlab-cloud-web/code-agent-status.test.mjs index 4d5f3013..874e3fb4 100644 --- a/web/hwlab-cloud-web/code-agent-status.test.mjs +++ b/web/hwlab-cloud-web/code-agent-status.test.mjs @@ -52,6 +52,34 @@ test("summarizes long-lived Code Agent as green with current deployment revision assert.equal(Object.keys(summary.fields).includes("sourceMain"), false); }); +test("does not mark Codex stdio-shaped fields green without runner and gate evidence", () => { + const summary = classifyCodeAgentStatusSummary({ + availability: { + status: "partial", + ready: true, + provider: "openai-responses", + mode: "openai", + backend: "hwlab-cloud-api/openai-responses", + capabilityLevel: "long-lived-codex-stdio-session", + sessionMode: "codex-mcp-stdio-long-lived", + runner: { + kind: "openai-responses-fallback", + codexStdio: false, + writeCapable: false + }, + longLivedSessionGate: { + status: "blocked", + pass: false, + blockers: [{ code: "openai_responses_fallback_not_session" }] + } + } + }); + + assert.notEqual(summary.kind, "long-lived-session"); + assert.notEqual(summary.tone, "ok"); + assert.equal(summary.readinessBlockers.includes("openai_responses_fallback_not_session"), true); +}); + test("maps degraded readonly session tools to warning, not green", () => { const summary = classifyCodeAgentStatusSummary({ availability: { diff --git a/web/hwlab-cloud-web/live-status.mjs b/web/hwlab-cloud-web/live-status.mjs index 3375d1f9..9c375227 100644 --- a/web/hwlab-cloud-web/live-status.mjs +++ b/web/hwlab-cloud-web/live-status.mjs @@ -397,7 +397,7 @@ function classifyStructuredCodeAgentProbe(readiness = {}) { const provider = readiness.provider?.provider ?? "not_observed"; const backend = readiness.provider?.backend ?? "not_observed"; const capability = readiness.sessionRunner?.capabilityLevel ?? "not_observed"; - if (readiness.ready === true && readiness.codexStdioFeasible === true) { + if (structuredCodexStdioReady(readiness)) { return passProbe({ serviceId, apiPath, @@ -425,6 +425,17 @@ function classifyStructuredCodeAgentProbe(readiness = {}) { }); } +function structuredCodexStdioReady(readiness = {}) { + return Boolean( + readiness.sessionRunnerReady === true && + readiness.codexStdioFeasible === true && + readiness.sessionRunner?.codexStdio === true && + readiness.sessionRunner?.writeCapable === true && + readiness.sessionRunner?.longLivedSession === true && + readiness.sessionRunner?.capabilityLevel === "long-lived-codex-stdio-session" + ); +} + function currentStructuredBlockers(readiness = {}) { return Array.isArray(readiness.currentBlockers) ? readiness.currentBlockers.filter(Boolean) 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 35bc28a8..0ffd7e89 100644 --- a/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs +++ b/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs @@ -789,6 +789,120 @@ test("keeps ready durable runtime green while attributing read-only mode to Code assert.doesNotMatch(healthLiveProbe.reason, /blocked/u); }); +test("classifies Codex stdio session facts as pass even when unrelated runtime durability is blocked", () => { + const status = classifyWorkbenchLiveStatus({ + healthLive: { + ok: true, + status: 200, + data: { + serviceId: "hwlab-cloud-api", + status: "degraded", + ready: false, + readiness: { + status: "degraded", + ready: false, + durability: { + status: "blocked", + ready: false, + blocker: "runtime_durable_adapter_query_blocked" + }, + codeAgent: { + status: "blocked", + ready: false, + providerReady: true, + durableDbReady: false, + sessionRunnerReady: true, + codexStdioFeasible: true, + currentBlocker: "runtime_durable_adapter_query_blocked", + currentBlockers: ["runtime_durable_adapter_query_blocked"], + provider: { + status: "ready", + ready: true, + provider: "codex-stdio", + backend: "hwlab-cloud-api/codex-mcp-stdio", + mode: "codex-stdio" + }, + sessionRunner: { + status: "codex_stdio_ready", + ready: true, + kind: "codex-mcp-stdio-runner", + mode: "codex-mcp-stdio-long-lived", + capabilityLevel: "long-lived-codex-stdio-session", + longLivedSession: true, + durableSession: true, + codexStdio: true, + writeCapable: true, + blocker: null + }, + codexStdio: { + status: "ready", + ready: true, + feasible: true, + blocker: null, + blockerCodes: [] + }, + secretMaterialRead: false, + valuesRedacted: 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", + sessionMode: "codex-mcp-stdio-long-lived", + workspace: "/workspace/hwlab", + sandbox: "workspace-write", + runner: { + kind: "codex-mcp-stdio-runner", + sessionMode: "codex-mcp-stdio-long-lived", + codexStdio: true, + writeCapable: true, + durableSession: true + }, + runnerLimitations: [ + "hardware-control-via-cloud-api-only", + "no-direct-gateway-link", + "no-direct-box-simu-link", + "no-direct-patch-panel-link", + "secret-values-redacted" + ], + longLivedSessionGate: { + status: "pass", + pass: true + }, + codexStdioFeasibility: { + ready: true, + canStartLongLivedCodexStdio: true, + commandProbe: { ready: true }, + blockerCodes: [] + } + }, + runtime: { + status: "degraded", + durable: true, + ready: false, + blocker: "runtime_durable_adapter_query_blocked" + } + } + }, + restIndex: okRest, + health: okApi, + adapter: okApi, + m3Control: okM3Control, + m3Status: okM3Status + }); + + const codeAgentProbe = status.probes.find((probe) => probe.apiPath === "POST /v1/agent/chat"); + assert.equal(codeAgentProbe.kind, "pass"); + assert.equal(codeAgentProbe.reasonCode, "codex_stdio_ready"); + assert.match(codeAgentProbe.reason, /capability=long-lived-codex-stdio-session/u); + assert.doesNotMatch(codeAgentProbe.reason, /not-codex-stdio|not-write-capable|process-local-session-registry/u); + assert.notEqual(status.reasonCode, "codex_stdio_blocked_readonly_session_available"); +}); + test("fixture matrix keeps durable_readiness_ready green and attributes read-only mode to Code Agent", () => { const restPayload = { serviceId: "hwlab-cloud-api",