feat: add codex stdio code agent session gate
This commit is contained in:
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+326
-224
@@ -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];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Generated
+123
@@ -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",
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
+88
-18
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user