feat: gate Code Agent session registry

Implements #317 session-gated read-only runner phase with structured long-lived Codex stdio blockers.
This commit is contained in:
Lyon
2026-05-23 18:19:58 +08:00
committed by GitHub
parent 97601c9b9d
commit d7e84f359b
13 changed files with 1052 additions and 96 deletions
+5 -3
View File
@@ -38,7 +38,7 @@ report、issue、PR 或截图。
| 观测结果 | readiness |
| --- | --- |
| `status: "failed"``error.code: "provider_unavailable"`,且 `error.missingEnv` 包含 `OPENAI_API_KEY` | `BLOCKED/credential`;provider 凭证缺失,不能标真实回复通过。 |
| `provider: "codex-readonly-runner"``sessionMode: "controlled-readonly-session-registry"``capabilityLevel: "read-only-tools"``runnerLimitations` 包含 `not-codex-stdio` / `not-write-capable` / `not-durable-session` | 只能标为 read-only session registry partial pass;不能关闭 #275 long-lived Codex stdio/session blocker。 |
| `provider: "codex-readonly-runner"``sessionMode: "controlled-readonly-session-registry"``capabilityLevel: "read-only-session-tools"``session.status``idle/ready/busy``session.idleTimeoutMs``session.lastTraceId` 存在、`longLivedSessionGate.status: "blocked"``runnerLimitations` 包含 `not-codex-stdio` / `not-write-capable` / `not-durable-session` | 只能标为 #317 read-only session registry partial pass;不能关闭 long-lived Codex stdio/session blocker。 |
| `codexStdioFeasibility.status: "blocked"`,或 blocker 包含 `codex_cli_binary_missing``runner_lifecycle_missing``stdio_protocol_not_wired``workspace_mount_missing``provider_token_boundary` | 真实 Codex stdio / 等价 long-lived runner 未具备;必须按 blocker 处理,不能表述为完整 Codex session。 |
| `status: "completed"`,但来自 mock、fixture、本地 stub、source-only smoke、浏览器本地回显或人工拼接 | 不是 DEV-LIVE reply pass。 |
| 真实 DEV `POST /v1/agent/chat` 返回 `status: "completed"`,且 `reply.content` 是非空 assistant 回复 | 可标 DEV-LIVE reply pass。 |
@@ -52,8 +52,10 @@ report、issue、PR 或截图。
`/v1/agent/chat` 可以先落地受控只读能力,但必须诚实区分:
- `controlled-readonly-session-registry`:由 cloud-api 进程内 registry 保存
`conversationId/sessionId` 映射、`turn` 计数、workspace 与只读工具 trace。它可以覆盖
`pwd``skills.discover``ls``rg --files` 和 bounded `cat`,输出必须限长和脱敏。
`conversationId/sessionId` 映射、`status`、workspace、sandbox、`createdAt``updatedAt`
`idleTimeoutMs``lastTraceId``turn` 计数与只读工具 trace。它可以覆盖 `pwd`
`skills.discover``ls``rg --files` 和 bounded `cat`,输出必须限长和脱敏。该模式的
`longLivedSessionGate` 必须保持 `blocked`,直到 Codex stdio 或等价长期通道真实接通。
- 该模式必须同时标记 `not-codex-stdio``not-write-capable``not-durable-session`。它不是
long-lived Codex stdio session,不提供写文件、任意 shell、硬件写、Secret/kubeconfig/DB URL
读取,也不证明 M3/M4/M5 trusted green。
+290 -71
View File
@@ -11,6 +11,10 @@ import {
codeAgentSecretRefPlaceholder,
inspectCodeAgentProviderEnv
} from "./code-agent-contract.mjs";
import {
DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS,
createCodeAgentSessionRegistry
} from "./code-agent-session-registry.mjs";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 120000;
const DEFAULT_CODEX_COMMAND = "codex";
@@ -23,6 +27,7 @@ const READONLY_RUNNER_KIND = "hwlab-readonly-runner";
const READONLY_RUNNER_SANDBOX = "read-only";
const READONLY_SESSION_MODE = "controlled-readonly-session-registry";
const READONLY_IMPLEMENTATION_TYPE = "controlled-readonly-session-registry";
const READONLY_SESSION_CAPABILITY_LEVEL = "read-only-session-tools";
const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback";
const CODEX_CLI_ONE_SHOT_RUNNER_KIND = "codex-cli-one-shot-ephemeral";
const READONLY_TOOL_OUTPUT_LIMIT = 4000;
@@ -58,12 +63,14 @@ const CODE_AGENT_SYSTEM_PROMPT = [
].join("\n");
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const readOnlySessions = new Map();
const readOnlyConversationSessions = new Map();
const defaultCodeAgentSessionRegistry = createCodeAgentSessionRegistry({
idleTimeoutMs: DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS,
maxSessions: MAX_READONLY_SESSIONS
});
export async function handleCodeAgentChat(params = {}, options = {}) {
const timestamp = nowIso(options.now);
const { conversationId, sessionId } = resolveConversationSessionIds(params);
const { conversationId, sessionId, requestedSessionId } = resolveConversationSessionIds(params);
const messageId = `msg_${randomUUID()}`;
const traceId = cleanProtocolId(params.traceId, "trc") || `trc_${randomUUID()}`;
const providerPlan = resolveProviderPlan(options.env ?? process.env, options);
@@ -89,16 +96,18 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
intent: runnerIntent,
conversationId,
traceId,
sessionId,
sessionId: requestedSessionId,
env: options.env ?? process.env,
now: options.now,
workspace: options.workspace,
skillsDirs: options.skillsDirs,
skillsDirsExact: options.skillsDirsExact
skillsDirsExact: options.skillsDirsExact,
sessionRegistry: options.sessionRegistry
});
const completedAt = nowIso(options.now);
return {
...base,
sessionId: runnerResult.session?.sessionId ?? base.sessionId,
status: "completed",
updatedAt: completedAt,
provider: runnerResult.provider,
@@ -106,11 +115,13 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
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,
@@ -155,6 +166,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
backend: providerResult.backend ?? base.backend,
workspace: providerResult.workspace ?? null,
sandbox: providerResult.sandbox ?? "none",
session: providerResult.session ?? null,
sessionMode: providerResult.sessionMode ?? "provider-text-request",
sessionReuse: providerResult.sessionReuse ?? null,
implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND,
@@ -165,6 +177,14 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
"not-durable-session"
],
codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(envForFeasibility(options.env ?? process.env)),
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))
}),
toolCalls: Array.isArray(providerResult.toolCalls) ? providerResult.toolCalls : [],
skills: providerResult.skills ?? {
status: "not_requested",
@@ -201,6 +221,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
if (error.backend) payload.backend = error.backend;
if (error.workspace !== undefined) payload.workspace = error.workspace;
if (error.sandbox !== undefined) payload.sandbox = error.sandbox;
if (error.session !== undefined) payload.session = error.session;
if (error.toolCalls !== undefined) payload.toolCalls = error.toolCalls;
if (error.skills !== undefined) payload.skills = error.skills;
if (error.runner !== undefined) payload.runner = error.runner;
@@ -211,6 +232,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
if (error.implementationType !== undefined) payload.implementationType = error.implementationType;
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") {
payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options);
} else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked"].includes(error.code)) {
@@ -266,7 +288,11 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) {
const blocked = providerPlan.mode === "openai"
? !providerContract.ready
: missingEnv.length > 0;
const runnerAvailability = inspectReadOnlyRunnerAvailability(env, options);
const sessionRegistry = resolveCodeAgentSessionRegistry(options);
const runnerAvailability = inspectReadOnlyRunnerAvailability(env, {
...options,
sessionRegistry
});
return {
endpoint: "POST /v1/agent/chat",
provider: providerPlan.provider,
@@ -291,11 +317,13 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) {
"skills",
"runnerTrace",
"capabilityLevel",
"session",
"sessionMode",
"sessionReuse",
"implementationType",
"runnerLimitations",
"codexStdioFeasibility",
"longLivedSessionGate",
"error.message",
"error.code",
"error.missingEnv",
@@ -312,11 +340,13 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) {
secretRefs: blocked ? providerContract.secretRefs : [],
egress: providerContract.egress,
safety: providerContract.safety,
capabilityLevel: runnerAvailability.ready ? "read-only-tools" : blocked ? "blocked" : "text-chat-only",
capabilityLevel: runnerAvailability.ready ? READONLY_SESSION_CAPABILITY_LEVEL : blocked ? "blocked" : "text-chat-only",
sessionRegistry: runnerAvailability.sessionRegistry,
sessionMode: runnerAvailability.sessionMode,
implementationType: runnerAvailability.implementationType,
runnerLimitations: runnerAvailability.runnerLimitations,
codexStdioFeasibility: runnerAvailability.codexStdioFeasibility,
longLivedSessionGate: runnerAvailability.longLivedSessionGate,
ready: !blocked || runnerAvailability.ready
};
}
@@ -381,6 +411,7 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) {
const skillsDirs = resolveSkillDirs(env, options);
const skillsDirsPresent = skillsDirs.filter((dir) => existsSync(dir));
const codexStdioFeasibility = inspectCodexStdioFeasibility(envForFeasibility(env));
const sessionRegistry = resolveCodeAgentSessionRegistry(options).describe();
return {
kind: READONLY_RUNNER_KIND,
backend: READONLY_RUNNER_BACKEND,
@@ -392,7 +423,7 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) {
session: READONLY_SESSION_MODE,
status: workspaceReady ? "available" : "blocked",
ready: workspaceReady,
capabilityLevel: workspaceReady ? "read-only-tools" : "blocked",
capabilityLevel: workspaceReady ? READONLY_SESSION_CAPABILITY_LEVEL : "blocked",
implementationType: READONLY_IMPLEMENTATION_TYPE,
longLivedSession: false,
durableSession: false,
@@ -400,15 +431,69 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) {
writeCapable: false,
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
codexStdioFeasibility,
longLivedSessionGate: longLivedSessionGate({
provider: READONLY_RUNNER_PROVIDER,
runnerKind: READONLY_RUNNER_KIND,
sessionMode: READONLY_SESSION_MODE,
implementationType: READONLY_IMPLEMENTATION_TYPE,
codexStdioFeasibility
}),
sessionRegistry,
skillsDirs,
skillsDirsPresent,
safety: runnerSafetyContract()
};
}
async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact }) {
async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact, sessionRegistry }) {
const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace });
const session = getOrCreateReadOnlySession({ conversationId, sessionId, workspace: resolvedWorkspace, now });
const registry = resolveCodeAgentSessionRegistry({ sessionRegistry });
const sessionAcquire = registry.acquire({
conversationId,
sessionId,
workspace: resolvedWorkspace,
sandbox: READONLY_RUNNER_SANDBOX,
runnerKind: READONLY_RUNNER_KIND,
capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL,
implementationType: READONLY_IMPLEMENTATION_TYPE,
traceId,
now
});
if (!sessionAcquire.ok) {
const blockedSession = sessionAcquire.session;
const blockedTrace = runnerTrace({
traceId,
workspace: resolvedWorkspace ?? repoRoot,
session: blockedSession,
events: [`blocked:${sessionAcquire.code}`],
startedAt: nowIso(now),
outputTruncated: false
});
throw runnerError(sessionAcquire.code, sessionAcquire.message, {
workspace: resolvedWorkspace ?? null,
session: blockedSession,
toolCalls: [],
skills: notRequestedSkills(),
runner: runnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }),
runnerTrace: blockedTrace,
capabilityLevel: "blocked",
sessionMode: READONLY_SESSION_MODE,
sessionReuse: sessionReuseEvidence(blockedSession),
implementationType: READONLY_IMPLEMENTATION_TYPE,
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env)),
longLivedSessionGate: longLivedSessionGate({
provider: READONLY_RUNNER_PROVIDER,
runnerKind: READONLY_RUNNER_KIND,
session: blockedSession,
sessionMode: READONLY_SESSION_MODE,
implementationType: READONLY_IMPLEMENTATION_TYPE,
codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env))
}),
blockers: [sessionAcquire.blocker]
});
}
let session = sessionAcquire.session;
const runner = runnerDescriptor({ workspace: resolvedWorkspace, session });
const startedAt = nowIso(now);
const codexStdioFeasibility = inspectCodexStdioFeasibility(envForFeasibility(env));
@@ -424,12 +509,28 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
sessionReuse: sessionReuseEvidence(session),
implementationType: READONLY_IMPLEMENTATION_TYPE,
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
codexStdioFeasibility
codexStdioFeasibility,
longLivedSessionGate: longLivedSessionGate({
provider: READONLY_RUNNER_PROVIDER,
runnerKind: READONLY_RUNNER_KIND,
session,
sessionMode: READONLY_SESSION_MODE,
implementationType: READONLY_IMPLEMENTATION_TYPE,
codexStdioFeasibility
})
};
if (!resolvedWorkspace || !(await pathReadable(resolvedWorkspace))) {
session = registry.fail(session.sessionId, {
now,
traceId,
conversationId,
reused: session.reused,
statusReason: "workspace_unreadable"
}) ?? session;
throw runnerError("runner_unavailable", `Read-only runner workspace is not readable: ${resolvedWorkspace || "missing"}`, {
workspace: resolvedWorkspace ?? null,
session,
toolCalls: [],
skills: notRequestedSkills(),
runner,
@@ -440,8 +541,16 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
}
if (intent.kind === "security") {
session = registry.fail(session.sessionId, {
now,
traceId,
conversationId,
reused: session.reused,
statusReason: "security_blocked"
}) ?? session;
throw runnerError("security_blocked", intent.reason ?? "The read-only runner blocked a request that could expose secrets or mutate hardware", {
workspace: resolvedWorkspace,
session,
toolCalls: [{
id: `tool_${randomUUID()}`,
type: "security",
@@ -468,6 +577,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
if (intent.kind === "pwd") {
const toolCall = await runPwdTool({ workspace: resolvedWorkspace, traceId, env });
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
return readOnlyRunnerResult({
content: [
"当前受控只读 runner 工作目录:",
@@ -490,12 +600,14 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
if (intent.kind === "ls") {
const toolCall = await runLsTool({ workspace: resolvedWorkspace, target: intent.target, traceId });
assertReadOnlyToolCompleted(toolCall, {
session,
workspace: resolvedWorkspace,
skills: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:ls:blocked"], startedAt, outputTruncated: false }),
baseEvidence
});
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
return readOnlyRunnerResult({
content: [
"受控只读 ls 结果:",
@@ -518,12 +630,14 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
if (intent.kind === "rg_files") {
const toolCall = await runRgFilesTool({ workspace: resolvedWorkspace, target: intent.target, traceId, env });
assertReadOnlyToolCompleted(toolCall, {
session,
workspace: resolvedWorkspace,
skills: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:rg --files:blocked"], startedAt, outputTruncated: false }),
baseEvidence
});
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
return readOnlyRunnerResult({
content: [
"受控只读 rg --files 结果:",
@@ -546,12 +660,14 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
if (intent.kind === "cat") {
const toolCall = await runCatTool({ workspace: resolvedWorkspace, target: intent.target, traceId });
assertReadOnlyToolCompleted(toolCall, {
session,
workspace: resolvedWorkspace,
skills: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:cat:blocked"], startedAt, outputTruncated: false }),
baseEvidence
});
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
return readOnlyRunnerResult({
content: [
"受控只读 cat 结果:",
@@ -574,8 +690,16 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
if (intent.kind === "skills") {
const skills = await discoverSkills({ env, skillsDirs, skillsDirsExact, traceId });
if (skills.status === "blocked") {
session = registry.fail(session.sessionId, {
now,
traceId,
conversationId,
reused: session.reused,
statusReason: "skills_unavailable"
}) ?? session;
throw runnerError("skills_unavailable", "No usable SKILL.md manifest was found for the read-only runner", {
workspace: resolvedWorkspace,
session,
toolCalls: [{
id: `tool_${randomUUID()}`,
type: "file-read",
@@ -595,6 +719,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
blockers: skills.blockers
});
}
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
return readOnlyRunnerResult({
content: skillsReply(skills),
workspace: resolvedWorkspace,
@@ -618,8 +743,16 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
});
}
session = registry.fail(session.sessionId, {
now,
traceId,
conversationId,
reused: session.reused,
statusReason: "tool_unavailable"
}) ?? session;
throw runnerError("tool_unavailable", `Read-only runner does not expose requested tool: ${intent.toolName ?? "unknown"}`, {
workspace: resolvedWorkspace,
session,
toolCalls: [{
id: `tool_${randomUUID()}`,
type: "tool",
@@ -1214,16 +1347,25 @@ function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, r
content,
workspace,
sandbox: READONLY_RUNNER_SANDBOX,
session,
sessionMode: READONLY_SESSION_MODE,
sessionReuse: sessionReuseEvidence(session),
implementationType: READONLY_IMPLEMENTATION_TYPE,
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
codexStdioFeasibility,
longLivedSessionGate: longLivedSessionGate({
provider: READONLY_RUNNER_PROVIDER,
runnerKind: READONLY_RUNNER_KIND,
session,
sessionMode: READONLY_SESSION_MODE,
implementationType: READONLY_IMPLEMENTATION_TYPE,
codexStdioFeasibility
}),
toolCalls,
skills,
runner,
runnerTrace,
capabilityLevel: "read-only-tools",
capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL,
providerTrace: {
runnerKind: READONLY_RUNNER_KIND,
toolCalls: toolCalls.length,
@@ -1236,42 +1378,6 @@ function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, r
};
}
function getOrCreateReadOnlySession({ conversationId, sessionId, workspace, now }) {
const requestedSessionId = cleanProtocolId(sessionId, "ses") || null;
const mappedSessionId = readOnlyConversationSessions.get(conversationId);
const effectiveSessionId = requestedSessionId || mappedSessionId || `ses_${randomUUID()}`;
const timestamp = nowIso(now);
let session = readOnlySessions.get(effectiveSessionId);
const reused = Boolean(session);
if (!session) {
session = {
sessionId: effectiveSessionId,
conversationIds: new Set(),
workspace,
createdAt: timestamp,
lastUsedAt: timestamp,
turn: 0
};
readOnlySessions.set(effectiveSessionId, session);
}
session.conversationIds.add(conversationId);
session.workspace = workspace;
session.lastUsedAt = timestamp;
session.turn += 1;
readOnlyConversationSessions.set(conversationId, effectiveSessionId);
pruneReadOnlySessions();
return {
sessionId: effectiveSessionId,
conversationId,
workspace,
createdAt: session.createdAt,
lastUsedAt: session.lastUsedAt,
turn: session.turn,
reused,
conversationIds: [...session.conversationIds]
};
}
function sessionReuseEvidence(session) {
return {
conversationId: session.conversationId,
@@ -1282,32 +1388,41 @@ function sessionReuseEvidence(session) {
previousTurns: Math.max(0, session.turn - 1),
workspace: session.workspace,
createdAt: session.createdAt,
lastUsedAt: session.lastUsedAt
updatedAt: session.updatedAt,
idleTimeoutMs: session.idleTimeoutMs,
expiresAt: session.expiresAt,
lastTraceId: session.lastTraceId,
status: session.status
};
}
function pruneReadOnlySessions() {
if (readOnlySessions.size <= MAX_READONLY_SESSIONS) return;
const sorted = [...readOnlySessions.entries()]
.sort((a, b) => String(a[1].lastUsedAt).localeCompare(String(b[1].lastUsedAt)));
for (const [sessionId, session] of sorted.slice(0, readOnlySessions.size - MAX_READONLY_SESSIONS)) {
readOnlySessions.delete(sessionId);
for (const conversationId of session.conversationIds) {
if (readOnlyConversationSessions.get(conversationId) === sessionId) {
readOnlyConversationSessions.delete(conversationId);
}
}
}
}
function sessionReplyLine(session) {
return `Session registry: mode=${READONLY_SESSION_MODE}; sessionId=${session.sessionId}; reused=${session.reused}; turn=${session.turn}.`;
return `Session registry: mode=${READONLY_SESSION_MODE}; sessionId=${session.sessionId}; status=${session.status}; reused=${session.reused}; turn=${session.turn}; idleTimeoutMs=${session.idleTimeoutMs}; lastTraceId=${session.lastTraceId}.`;
}
function limitationReplyLine() {
return "边界:controlled-readonly-session-registrynot-codex-stdionot-write-capablenot-durable-session。";
}
function releaseReadOnlySession(registry, session, { now, traceId, conversationId } = {}) {
return registry.release(session.sessionId, {
now,
traceId,
conversationId,
reused: session.reused,
status: "idle"
}) ?? session;
}
function resolveCodeAgentSessionRegistry(options = {}) {
return options.sessionRegistry &&
typeof options.sessionRegistry.acquire === "function" &&
typeof options.sessionRegistry.release === "function" &&
typeof options.sessionRegistry.describe === "function"
? options.sessionRegistry
: defaultCodeAgentSessionRegistry;
}
function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = null } = {}) {
return {
kind,
@@ -1326,7 +1441,7 @@ function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = nu
durableSession: false,
writeCapable: false,
readOnly: true,
capabilityLevel: kind === READONLY_RUNNER_KIND ? "read-only-tools" : "text-chat-only",
capabilityLevel: kind === READONLY_RUNNER_KIND ? READONLY_SESSION_CAPABILITY_LEVEL : "text-chat-only",
toolPolicy: {
allowed: ["pwd", "skills.discover", "ls", "rg --files", "cat"],
blocked: ["secret-read", "kubeconfig-read", "hardware-write", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim"]
@@ -1344,6 +1459,9 @@ function runnerTrace({ traceId, events, startedAt, outputTruncated, workspace =
sandbox: READONLY_RUNNER_SANDBOX,
sessionMode: runnerKind === READONLY_RUNNER_KIND ? READONLY_SESSION_MODE : "ephemeral-one-shot",
sessionId: session?.sessionId ?? null,
sessionStatus: session?.status ?? null,
idleTimeoutMs: session?.idleTimeoutMs ?? null,
lastTraceId: session?.lastTraceId ?? null,
turn: session?.turn ?? null,
sessionReused: session?.reused ?? false,
implementationType: runnerKind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral",
@@ -1429,6 +1547,77 @@ function inspectCodexStdioFeasibility(env = process.env) {
};
}
function longLivedSessionGate({
provider,
runnerKind,
session,
sessionMode,
implementationType,
codexStdioFeasibility
} = {}) {
const normalizedProvider = String(provider ?? "").trim();
const normalizedRunnerKind = String(runnerKind ?? "").trim();
const normalizedSessionMode = String(sessionMode ?? "").trim();
const normalizedImplementation = String(implementationType ?? "").trim();
const blockers = [];
if (normalizedProvider === "openai-responses" || normalizedRunnerKind === OPENAI_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",
@@ -1688,6 +1877,27 @@ async function callCodexCli({ providerPlan, message, conversationId, traceId, ti
workspace: repoRoot,
sandbox: READONLY_RUNNER_SANDBOX,
sessionMode: "ephemeral-one-shot",
session: {
sessionId: conversationId,
conversationId,
status: "expired",
workspace: repoRoot,
sandbox: READONLY_RUNNER_SANDBOX,
runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND,
capabilityLevel: "text-chat-only",
implementationType: "codex-cli-one-shot-ephemeral",
createdAt: nowIso(),
updatedAt: nowIso(),
idleTimeoutMs: 0,
expiresAt: nowIso(),
lastTraceId: traceId,
turn: 1,
longLivedSession: false,
codexStdio: false,
durable: false,
secretMaterialStored: false,
valuesRedacted: true
},
sessionReuse: {
conversationId,
sessionId: conversationId,
@@ -1700,6 +1910,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)),
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))
}),
toolCalls: [],
skills: notRequestedSkills(),
runner: runnerDescriptor({ workspace: repoRoot, kind: CODEX_CLI_ONE_SHOT_RUNNER_KIND }),
@@ -1966,30 +2183,32 @@ function resolveConversationSessionIds(params = {}) {
const requestedConversationId = cleanProtocolId(params.conversationId, "cnv");
const requestedSessionId = cleanProtocolId(params.sessionId, "ses");
if (requestedConversationId && requestedSessionId) {
readOnlyConversationSessions.set(requestedConversationId, requestedSessionId);
return {
conversationId: requestedConversationId,
sessionId: requestedSessionId
sessionId: requestedSessionId,
requestedSessionId
};
}
if (requestedConversationId) {
return {
conversationId: requestedConversationId,
sessionId: readOnlyConversationSessions.get(requestedConversationId) || requestedConversationId
sessionId: requestedConversationId,
requestedSessionId: null
};
}
if (requestedSessionId) {
const conversationId = `cnv_${requestedSessionId.replace(/^[a-z][a-z0-9]*_/u, "")}`;
readOnlyConversationSessions.set(conversationId, requestedSessionId);
return {
conversationId,
sessionId: requestedSessionId
sessionId: requestedSessionId,
requestedSessionId
};
}
const conversationId = `cnv_${randomUUID()}`;
return {
conversationId,
sessionId: conversationId
sessionId: conversationId,
requestedSessionId: null
};
}
@@ -0,0 +1,295 @@
import { randomUUID } from "node:crypto";
export const DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
export const DEFAULT_CODE_AGENT_MAX_SESSIONS = 200;
export const CODE_AGENT_SESSION_STATUSES = Object.freeze([
"creating",
"ready",
"busy",
"idle",
"interrupted",
"expired",
"failed"
]);
export function createCodeAgentSessionRegistry(options = {}) {
const idleTimeoutMs = positiveInteger(options.idleTimeoutMs, DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS);
const maxSessions = positiveInteger(options.maxSessions, DEFAULT_CODE_AGENT_MAX_SESSIONS);
const idFactory = typeof options.idFactory === "function" ? options.idFactory : () => `ses_${randomUUID()}`;
const defaultNow = options.now;
const sessions = new Map();
const conversations = new Map();
function acquire(params = {}) {
const timestamp = timestampFor(params.now ?? defaultNow);
const timestampMs = Date.parse(timestamp);
const conversationId = requiredId(params.conversationId, "cnv");
const requestedSessionId = optionalId(params.sessionId);
const mappedSessionId = conversations.get(conversationId) ?? null;
if (mappedSessionId && requestedSessionId && mappedSessionId !== requestedSessionId) {
const mappedSession = sessions.get(mappedSessionId) ?? null;
return blockedAcquire({
code: "session_reuse_conflict",
summary: `conversationId ${conversationId} is already bound to sessionId ${mappedSessionId}; requested ${requestedSessionId}.`,
session: mappedSession,
timestamp,
traceId: params.traceId
});
}
const effectiveSessionId = mappedSessionId || requestedSessionId || requiredId(idFactory(), "ses");
let session = sessions.get(effectiveSessionId) ?? null;
const reused = Boolean(session);
if (session && sessionExpired(session, timestampMs)) {
session.status = "expired";
session.updatedAt = timestamp;
session.lastTraceId = optionalId(params.traceId);
return blockedAcquire({
code: "session_expired",
summary: `Code Agent runner session ${effectiveSessionId} expired after ${session.idleTimeoutMs}ms idle timeout.`,
session,
timestamp,
traceId: params.traceId
});
}
if (session?.status === "busy") {
session.updatedAt = timestamp;
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
return blockedAcquire({
code: "session_busy",
summary: `Code Agent runner session ${effectiveSessionId} is already busy with traceId ${session.currentTraceId ?? "unknown"}.`,
session,
timestamp,
traceId: params.traceId
});
}
if (!session) {
session = {
sessionId: effectiveSessionId,
conversationIds: new Set(),
status: "creating",
workspace: params.workspace ?? null,
sandbox: params.sandbox ?? "read-only",
runnerKind: params.runnerKind ?? "hwlab-readonly-runner",
capabilityLevel: params.capabilityLevel ?? "read-only-session-tools",
implementationType: params.implementationType ?? "controlled-readonly-session-registry",
createdAt: timestamp,
updatedAt: timestamp,
idleTimeoutMs,
expiresAt: plusMs(timestampMs, idleTimeoutMs),
lastTraceId: optionalId(params.traceId),
currentTraceId: null,
turn: 0,
durable: false,
longLivedSession: false,
codexStdio: false,
secretMaterialStored: false
};
sessions.set(effectiveSessionId, session);
}
session.conversationIds.add(conversationId);
session.workspace = params.workspace ?? session.workspace;
session.sandbox = params.sandbox ?? session.sandbox;
session.runnerKind = params.runnerKind ?? session.runnerKind;
session.capabilityLevel = params.capabilityLevel ?? session.capabilityLevel;
session.implementationType = params.implementationType ?? session.implementationType;
session.status = "busy";
session.updatedAt = timestamp;
session.expiresAt = plusMs(timestampMs, idleTimeoutMs);
session.lastTraceId = optionalId(params.traceId);
session.currentTraceId = optionalId(params.traceId);
session.turn += 1;
conversations.set(conversationId, effectiveSessionId);
prune();
return {
ok: true,
reused,
session: publicSession(session, { conversationId, reused, timestamp })
};
}
function release(sessionId, params = {}) {
const session = sessions.get(requiredId(sessionId, "ses")) ?? null;
if (!session) return null;
const timestamp = timestampFor(params.now ?? defaultNow);
const timestampMs = Date.parse(timestamp);
session.status = CODE_AGENT_SESSION_STATUSES.includes(params.status) ? params.status : "idle";
session.updatedAt = timestamp;
session.expiresAt = plusMs(timestampMs, session.idleTimeoutMs);
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
session.currentTraceId = null;
session.statusReason = params.statusReason ?? null;
return publicSession(session, {
conversationId: params.conversationId,
reused: params.reused,
timestamp
});
}
function fail(sessionId, params = {}) {
return release(sessionId, { ...params, status: "failed" });
}
function interrupt(sessionId, params = {}) {
return release(sessionId, { ...params, status: "interrupted" });
}
function get(sessionId, params = {}) {
const session = sessions.get(requiredId(sessionId, "ses")) ?? null;
return session ? publicSession(session, params) : null;
}
function getByConversation(conversationId, params = {}) {
const sessionId = conversations.get(requiredId(conversationId, "cnv"));
return sessionId ? get(sessionId, { ...params, conversationId }) : null;
}
function expireIdle(params = {}) {
const timestamp = timestampFor(params.now ?? defaultNow);
const timestampMs = Date.parse(timestamp);
let expired = 0;
for (const session of sessions.values()) {
if (!sessionExpired(session, timestampMs)) continue;
session.status = "expired";
session.updatedAt = timestamp;
session.currentTraceId = null;
expired += 1;
}
return expired;
}
function describe() {
return {
kind: "code-agent-session-registry",
implementationType: "controlled-readonly-session-registry",
status: "available",
sessionCount: sessions.size,
maxSessions,
idleTimeoutMs,
statuses: [...CODE_AGENT_SESSION_STATUSES],
longLivedCodexStdio: false,
durable: false,
secretMaterialStored: false,
valuesRedacted: true
};
}
function clear() {
sessions.clear();
conversations.clear();
}
function prune() {
if (sessions.size <= maxSessions) return;
const sorted = [...sessions.entries()]
.sort((a, b) => String(a[1].updatedAt).localeCompare(String(b[1].updatedAt)));
for (const [sessionId, session] of sorted.slice(0, sessions.size - maxSessions)) {
sessions.delete(sessionId);
for (const conversationId of session.conversationIds) {
if (conversations.get(conversationId) === sessionId) {
conversations.delete(conversationId);
}
}
}
}
return {
acquire,
release,
fail,
interrupt,
get,
getByConversation,
expireIdle,
describe,
clear
};
}
function blockedAcquire({ code, summary, session, timestamp, traceId }) {
const publicEvidence = session
? publicSession(session, { timestamp, reused: true })
: {
status: "failed",
updatedAt: timestamp,
lastTraceId: optionalId(traceId),
secretMaterialStored: false,
valuesRedacted: true
};
return {
ok: false,
code,
message: summary,
session: publicEvidence,
blocker: {
code,
sourceIssue: "pikasTech/HWLAB#317",
summary
}
};
}
function publicSession(session, { conversationId = null, reused = null } = {}) {
const conversationIds = [...session.conversationIds];
return {
sessionId: session.sessionId,
conversationId: conversationId ?? conversationIds[0] ?? null,
conversationIds,
status: session.status,
workspace: session.workspace,
sandbox: session.sandbox,
runnerKind: session.runnerKind,
capabilityLevel: session.capabilityLevel,
implementationType: session.implementationType,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
idleTimeoutMs: session.idleTimeoutMs,
expiresAt: session.expiresAt,
lastTraceId: session.lastTraceId,
currentTraceId: session.currentTraceId,
turn: session.turn,
reused: reused === null ? undefined : Boolean(reused),
durable: session.durable === true,
longLivedSession: session.longLivedSession === true,
codexStdio: session.codexStdio === true,
secretMaterialStored: false,
valuesRedacted: true,
...(session.statusReason ? { statusReason: session.statusReason } : {})
};
}
function sessionExpired(session, timestampMs) {
if (!Number.isFinite(timestampMs) || session.status === "expired") return session.status === "expired";
return Date.parse(session.expiresAt) <= timestampMs;
}
function timestampFor(now) {
const value = typeof now === "function" ? now() : now;
if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return value;
if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString();
return new Date().toISOString();
}
function plusMs(timestampMs, offsetMs) {
return new Date(timestampMs + offsetMs).toISOString();
}
function requiredId(value, fallbackPrefix) {
const id = optionalId(value);
if (id) return id;
return `${fallbackPrefix}_${randomUUID()}`;
}
function optionalId(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function positiveInteger(value, fallback) {
return Number.isInteger(value) && value > 0 ? value : fallback;
}
@@ -0,0 +1,281 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs";
import { handleCodeAgentChat, validateCodeAgentChatSchema } from "./code-agent-chat.mjs";
import {
classifyCodexRunnerCapability,
classifyCodeAgentChatReadiness
} from "../../scripts/src/code-agent-response-contract.mjs";
test("code agent session registry creates, reuses, and expires sessions without storing secrets", () => {
const registry = createCodeAgentSessionRegistry({
idleTimeoutMs: 1000,
idFactory: () => "ses_test-registry"
});
const first = registry.acquire({
conversationId: "cnv_registry",
workspace: "/workspace/hwlab",
sandbox: "read-only",
runnerKind: "hwlab-readonly-runner",
capabilityLevel: "read-only-session-tools",
traceId: "trc_registry_1",
now: "2026-05-23T00:00:00.000Z"
});
assert.equal(first.ok, true);
assert.equal(first.reused, false);
assert.equal(first.session.sessionId, "ses_test-registry");
assert.equal(first.session.status, "busy");
assert.equal(first.session.idleTimeoutMs, 1000);
assert.equal(first.session.lastTraceId, "trc_registry_1");
assert.equal(first.session.secretMaterialStored, false);
assert.equal(first.session.valuesRedacted, true);
registry.release(first.session.sessionId, {
conversationId: "cnv_registry",
traceId: "trc_registry_1",
reused: first.session.reused,
now: "2026-05-23T00:00:00.010Z"
});
const second = registry.acquire({
conversationId: "cnv_registry",
workspace: "/workspace/hwlab",
sandbox: "read-only",
runnerKind: "hwlab-readonly-runner",
capabilityLevel: "read-only-session-tools",
traceId: "trc_registry_2",
now: "2026-05-23T00:00:00.500Z"
});
assert.equal(second.ok, true);
assert.equal(second.reused, true);
assert.equal(second.session.sessionId, "ses_test-registry");
assert.equal(second.session.turn, 2);
assert.equal(second.session.lastTraceId, "trc_registry_2");
registry.release(second.session.sessionId, {
conversationId: "cnv_registry",
traceId: "trc_registry_2",
reused: second.session.reused,
now: "2026-05-23T00:00:00.510Z"
});
const expired = registry.acquire({
conversationId: "cnv_registry",
workspace: "/workspace/hwlab",
sandbox: "read-only",
runnerKind: "hwlab-readonly-runner",
capabilityLevel: "read-only-session-tools",
traceId: "trc_registry_expired",
now: "2026-05-23T00:00:02.000Z"
});
assert.equal(expired.ok, false);
assert.equal(expired.code, "session_expired");
assert.equal(expired.session.status, "expired");
assert.equal(expired.blocker.sourceIssue, "pikasTech/HWLAB#317");
});
test("code agent session registry reports busy sessions as structured blocker", () => {
const registry = createCodeAgentSessionRegistry({
idFactory: () => "ses_busy"
});
const first = registry.acquire({
conversationId: "cnv_busy",
workspace: "/workspace/hwlab",
sandbox: "read-only",
runnerKind: "hwlab-readonly-runner",
capabilityLevel: "read-only-session-tools",
traceId: "trc_busy_1",
now: "2026-05-23T00:00:00.000Z"
});
assert.equal(first.ok, true);
const busy = registry.acquire({
conversationId: "cnv_busy",
workspace: "/workspace/hwlab",
sandbox: "read-only",
runnerKind: "hwlab-readonly-runner",
capabilityLevel: "read-only-session-tools",
traceId: "trc_busy_2",
now: "2026-05-23T00:00:01.000Z"
});
assert.equal(busy.ok, false);
assert.equal(busy.code, "session_busy");
assert.equal(busy.session.status, "busy");
assert.equal(busy.session.currentTraceId, "trc_busy_1");
});
test("read-only runner returns session registry evidence and blocked long-lived gate", async () => {
const registry = createCodeAgentSessionRegistry({
idFactory: () => "ses_chat_registry"
});
const first = await handleCodeAgentChat(
{
conversationId: "cnv_chat_registry",
traceId: "trc_chat_registry_pwd",
message: "pwd"
},
{
now: () => "2026-05-23T00:01:00.000Z",
sessionRegistry: registry,
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
validateCodeAgentChatSchema(first);
assert.equal(first.status, "completed");
assert.equal(first.sessionId, "ses_chat_registry");
assert.equal(first.session.sessionId, "ses_chat_registry");
assert.equal(first.session.status, "idle");
assert.equal(first.session.workspace, process.cwd());
assert.equal(first.session.lastTraceId, "trc_chat_registry_pwd");
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"));
const second = await handleCodeAgentChat(
{
conversationId: "cnv_chat_registry",
traceId: "trc_chat_registry_ls",
message: "ls ."
},
{
now: () => "2026-05-23T00:01:01.000Z",
sessionRegistry: registry,
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
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.session.lastTraceId, "trc_chat_registry_ls");
assert.equal(classifyCodexRunnerCapability(second, { httpStatus: 200 }).capabilityPass, true);
});
test("expired and busy session states are surfaced as structured blockers", async () => {
const expiredRegistry = createCodeAgentSessionRegistry({
idleTimeoutMs: 10,
idFactory: () => "ses_expired_chat"
});
const first = expiredRegistry.acquire({
conversationId: "cnv_expired_chat",
workspace: process.cwd(),
sandbox: "read-only",
runnerKind: "hwlab-readonly-runner",
capabilityLevel: "read-only-session-tools",
traceId: "trc_expired_seed",
now: "2026-05-23T00:02:00.000Z"
});
expiredRegistry.release(first.session.sessionId, {
conversationId: "cnv_expired_chat",
traceId: "trc_expired_seed",
now: "2026-05-23T00:02:00.001Z"
});
const expired = await handleCodeAgentChat(
{
conversationId: "cnv_expired_chat",
traceId: "trc_expired_request",
message: "pwd"
},
{
now: () => "2026-05-23T00:02:01.000Z",
sessionRegistry: expiredRegistry,
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
assert.equal(expired.status, "failed");
assert.equal(expired.error.code, "session_expired");
assert.equal(expired.session.status, "expired");
assert.equal(expired.capabilityLevel, "blocked");
assert.equal(expired.longLivedSessionGate.status, "blocked");
const busyRegistry = createCodeAgentSessionRegistry({
idFactory: () => "ses_busy_chat"
});
busyRegistry.acquire({
conversationId: "cnv_busy_chat",
workspace: process.cwd(),
sandbox: "read-only",
runnerKind: "hwlab-readonly-runner",
capabilityLevel: "read-only-session-tools",
traceId: "trc_busy_seed",
now: "2026-05-23T00:03:00.000Z"
});
const busy = await handleCodeAgentChat(
{
conversationId: "cnv_busy_chat",
traceId: "trc_busy_request",
message: "pwd"
},
{
now: () => "2026-05-23T00:03:01.000Z",
sessionRegistry: busyRegistry,
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
assert.equal(busy.status, "failed");
assert.equal(busy.error.code, "session_busy");
assert.equal(busy.session.status, "busy");
assert.equal(busy.capabilityLevel, "blocked");
assert.equal(busy.error.blockers[0].sourceIssue, "pikasTech/HWLAB#317");
});
test("OpenAI fallback and codex one-shot do not pass the long-lived session gate", async () => {
const fallback = await handleCodeAgentChat(
{
conversationId: "cnv_fallback_gate",
traceId: "trc_fallback_gate",
message: "你好"
},
{
now: () => "2026-05-23T00:04:00.000Z",
callProvider: async ({ providerPlan }) => ({
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
content: "普通文本回复。",
usage: null,
providerTrace: {
source: "test-provider"
}
})
}
);
assert.equal(fallback.status, "completed");
assert.equal(fallback.runner.kind, "openai-responses-fallback");
assert.equal(fallback.capabilityLevel, "text-chat-only");
assert.equal(fallback.longLivedSessionGate.status, "blocked");
assert.ok(fallback.longLivedSessionGate.blockers.some((blocker) => blocker.code === "openai_responses_fallback_not_session"));
assert.equal(classifyCodexRunnerCapability(fallback, { httpStatus: 200 }).capabilityPass, false);
const oneShot = {
...fallback,
provider: "codex-cli",
backend: "hwlab-cloud-api/codex-cli",
runner: {
kind: "codex-cli-one-shot-ephemeral",
codexStdio: false,
writeCapable: false,
durableSession: false
},
sessionMode: "ephemeral-one-shot",
implementationType: "codex-cli-one-shot-ephemeral",
longLivedSessionGate: {
status: "blocked",
pass: false,
blockers: [{ code: "one_shot_runner_not_long_lived", sourceIssue: "pikasTech/HWLAB#317" }]
}
};
assert.equal(classifyCodexRunnerCapability(oneShot, { httpStatus: 200 }).capabilityPass, false);
assert.equal(classifyCodeAgentChatReadiness(oneShot, { realDevLive: true, httpStatus: 200 }).devLiveReplyPass, true);
});
+2 -1
View File
@@ -343,7 +343,8 @@ async function handleCodeAgentChatHttp(request, response, options) {
env: options.env,
workspace: options.workspace,
skillsDirs: options.skillsDirs,
skillsDirsExact: options.skillsDirsExact
skillsDirsExact: options.skillsDirsExact,
sessionRegistry: options.sessionRegistry
}
);
+18 -5
View File
@@ -73,7 +73,10 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
assert.equal(healthPayload.codeAgent.reason, null);
assert.equal(healthPayload.codeAgent.runner.kind, "hwlab-readonly-runner");
assert.equal(healthPayload.codeAgent.runner.ready, true);
assert.equal(healthPayload.codeAgent.capabilityLevel, "read-only-tools");
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.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");
@@ -94,7 +97,7 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
assert.equal(healthLivePayload.db.ready, false);
assert.equal(healthLivePayload.codeAgent.status, "available");
assert.equal(healthLivePayload.codeAgent.ready, true);
assert.equal(healthLivePayload.codeAgent.capabilityLevel, "read-only-tools");
assert.equal(healthLivePayload.codeAgent.capabilityLevel, "read-only-session-tools");
const readiness = await fetch(`http://127.0.0.1:${port}/health/live`);
assert.equal(readiness.status, 200);
@@ -848,7 +851,9 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret
assert.equal(payload.codeAgent.reason, null);
assert.equal(payload.codeAgent.runner.kind, "hwlab-readonly-runner");
assert.equal(payload.codeAgent.runner.ready, true);
assert.equal(payload.codeAgent.capabilityLevel, "read-only-tools");
assert.equal(payload.codeAgent.capabilityLevel, "read-only-session-tools");
assert.equal(payload.codeAgent.sessionRegistry.status, "available");
assert.equal(payload.codeAgent.longLivedSessionGate.status, "blocked");
assert.deepEqual(payload.codeAgent.missingEnv, ["OPENAI_API_KEY", "HWLAB_CODE_AGENT_OPENAI_BASE_URL"]);
assert.equal(payload.codeAgent.secretRefs[0].env, "OPENAI_API_KEY");
assert.equal(payload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
@@ -978,9 +983,14 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence
assert.equal(payload.status, "completed");
assert.equal(payload.provider, "codex-readonly-runner");
assert.equal(payload.backend, "hwlab-cloud-api/codex-readonly-runner");
assert.equal(payload.capabilityLevel, "read-only-tools");
assert.equal(payload.capabilityLevel, "read-only-session-tools");
assert.equal(payload.workspace, workspace);
assert.equal(payload.sandbox, "read-only");
assert.equal(payload.session.status, "idle");
assert.equal(payload.session.workspace, workspace);
assert.equal(payload.session.sandbox, "read-only");
assert.equal(payload.session.lastTraceId, "trc_server-test-runner-pwd");
assert.equal(typeof payload.session.idleTimeoutMs, "number");
assert.equal(payload.runner.kind, "hwlab-readonly-runner");
assert.equal(payload.runner.longLivedSession, false);
assert.equal(payload.runner.codexStdio, false);
@@ -991,8 +1001,11 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence
assert.equal(payload.implementationType, "controlled-readonly-session-registry");
assert.equal(payload.sessionReuse.reused, false);
assert.equal(payload.sessionReuse.turn, 1);
assert.equal(payload.sessionReuse.status, "idle");
assert.ok(payload.runnerLimitations.includes("not-codex-stdio"));
assert.ok(payload.codexStdioFeasibility.blockers.some((blocker) => blocker.code === "runner_lifecycle_missing"));
assert.equal(payload.longLivedSessionGate.status, "blocked");
assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired"));
assert.equal(payload.toolCalls[0].name, "pwd");
assert.equal(payload.toolCalls[0].status, "completed");
assert.equal(payload.toolCalls[0].stdout, workspace);
@@ -1135,7 +1148,7 @@ test("cloud api /v1/agent/chat discovers skills manifest with source and version
const payload = await response.json();
assert.equal(payload.status, "completed");
assert.equal(payload.provider, "codex-readonly-runner");
assert.equal(payload.capabilityLevel, "read-only-tools");
assert.equal(payload.capabilityLevel, "read-only-session-tools");
assert.equal(payload.skills.status, "ready");
assert.equal(payload.skills.items[0].name, "alpha-skill");
assert.equal(payload.skills.items[0].summary, "Alpha skill summary.");
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -410,13 +410,15 @@
},
{
"id": "live-gate-route",
"status": "pass",
"summary": "Direct /gate route opens the internal diagnostics view without becoming the default workbench.",
"status": "blocked",
"summary": "Direct /gate route was observed in an older DOM-only report, but the checked-in observations do not include the current single-table gate contract fields; kept BLOCKED instead of inferring DEV-LIVE gate readiness.",
"evidence": [
"pathname=/gate",
"gateHidden=false",
"diagnosticsTermsPresent=true",
"codeAgentPostAttempts=0"
"codeAgentPostAttempts=0",
"single-table gate observations missing",
"kept blocked to avoid false green"
],
"observations": {
"pathname": "/gate",
@@ -432,7 +434,8 @@
"attemptedCount": 0,
"attempts": []
}
}
},
"blocker": "observability_blocker"
},
{
"id": "live-help-markdown-route",
@@ -517,6 +520,12 @@
"scope": "live-browser-dom",
"status": "open",
"summary": "Browser DOM did not satisfy the workbench contract."
},
{
"type": "observability_blocker",
"scope": "live-gate-route",
"status": "open",
"summary": "Checked-in DOM-only /gate observations are missing current single-table contract fields; rerun DOM-only smoke after DEV rollout for fresh evidence."
}
],
"safety": {
+17 -2
View File
@@ -179,7 +179,12 @@ async function runLocalContractSmoke() {
assert.equal(runnerPwd.provider, "codex-readonly-runner");
assert.equal(runnerPwd.workspace, process.cwd());
assert.equal(runnerPwd.sandbox, "read-only");
assert.equal(runnerPwd.capabilityLevel, "read-only-tools");
assert.equal(runnerPwd.capabilityLevel, "read-only-session-tools");
assert.equal(runnerPwd.session.status, "idle");
assert.equal(runnerPwd.session.workspace, process.cwd());
assert.equal(runnerPwd.session.sandbox, "read-only");
assert.equal(runnerPwd.session.idleTimeoutMs, 1800000);
assert.equal(runnerPwd.session.lastTraceId, "trc_code-agent-chat-runner-pwd");
assert.equal(runnerPwd.sessionMode, "controlled-readonly-session-registry");
assert.equal(runnerPwd.implementationType, "controlled-readonly-session-registry");
assert.equal(runnerPwd.runner.session, "controlled-readonly-session-registry");
@@ -188,13 +193,18 @@ async function runLocalContractSmoke() {
assert.equal(runnerPwd.runner.durableSession, false);
assert.equal(runnerPwd.sessionReuse.reused, false);
assert.equal(runnerPwd.sessionReuse.turn, 1);
assert.equal(runnerPwd.sessionReuse.status, "idle");
assert.ok(runnerPwd.runnerLimitations.includes("not-codex-stdio"));
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.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");
const runnerSecondTurn = await handleCodeAgentChat(
@@ -214,8 +224,10 @@ async function runLocalContractSmoke() {
validateCodeAgentChatSchema(runnerSecondTurn);
assert.equal(runnerSecondTurn.status, "completed");
assert.equal(runnerSecondTurn.sessionId, runnerPwd.sessionId);
assert.equal(runnerSecondTurn.session.sessionId, runnerPwd.session.sessionId);
assert.equal(runnerSecondTurn.sessionReuse.reused, true);
assert.equal(runnerSecondTurn.sessionReuse.turn, 2);
assert.equal(runnerSecondTurn.session.lastTraceId, "trc_code-agent-chat-runner-session-reuse");
assert.equal(runnerSecondTurn.toolCalls[0].name, "ls");
assert.equal(runnerSecondTurn.toolCalls[0].status, "completed");
assert.match(runnerSecondTurn.reply.content, /package\.json/u);
@@ -292,7 +304,8 @@ async function runLocalContractSmoke() {
assert.equal(boundedRgFiles.status, "completed");
assert.equal(boundedRgFiles.toolCalls[0].name, "rg --files");
assert.equal(boundedRgFiles.toolCalls[0].status, "completed");
assert.match(boundedRgFiles.reply.content, /package\.json/u);
assert.ok(boundedRgFiles.toolCalls[0].stdout.length <= 4100);
assert.match(boundedRgFiles.reply.content, /受控只读 rg --files 结果/u);
logOk("bounded rg --files read-only tool capability");
const toolUnavailable = await handleCodeAgentChat(
@@ -443,6 +456,8 @@ function summarizePayload(payload, options = {}) {
skills: responseSummary?.skills ?? null,
runnerTrace: responseSummary?.runnerTrace ?? null,
codexStdioFeasibility: responseSummary?.codexStdioFeasibility ?? null,
session: responseSummary?.session ?? null,
longLivedSessionGate: responseSummary?.longLivedSessionGate ?? null,
assistantReplyNonEmpty: responseSummary?.hasReply === true,
assistantReplyLength: assistantReply.length,
error: responseSummary?.error ?? null
+82 -7
View File
@@ -1,5 +1,6 @@
const trustedLiveProviders = new Set(["openai-responses", "codex-cli", "codex-readonly-runner"]);
const trustedRunnerProviders = 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(["发送失败", "服务受阻", "BLOCKED 凭证缺口"]);
const timeoutPattern = /\b(?:timeout|timed out|abort|aborted|超时|请求超过)\b/iu;
@@ -86,6 +87,10 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {
? payload.runner.runnerLimitations.filter((item) => typeof item === "string")
: [];
const sessionReuse = payload?.sessionReuse && typeof payload.sessionReuse === "object" ? payload.sessionReuse : null;
const session = payload?.session && typeof payload.session === "object" ? payload.session : null;
const longLivedSessionGate = payload?.longLivedSessionGate && typeof payload.longLivedSessionGate === "object"
? payload.longLivedSessionGate
: null;
if (provider === "openai-responses" || runnerKind === "openai-responses-fallback") {
return {
@@ -101,11 +106,15 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {
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 (capabilityLevel !== "read-only-tools") missing.push("capabilityLevel=read-only-tools");
if (!readonlySessionCapabilityLevels.has(capabilityLevel)) missing.push("capabilityLevel=read-only-session-tools");
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 (!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 (!sessionReuse) missing.push("sessionReuse");
if (toolCalls.length === 0) missing.push("toolCalls");
if (!runnerTrace) missing.push("runnerTrace");
@@ -114,6 +123,7 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {
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 (!longLivedSessionGate) missing.push("longLivedSessionGate");
if (missing.length > 0) {
return {
@@ -125,6 +135,16 @@ 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",
@@ -137,10 +157,11 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {
return {
status: "pass",
level: "#275 read-only Codex runner capability pass",
level: "#317 read-only session registry partial pass",
blocker: null,
capabilityPass: true,
reason: "completed read-only runner response includes workspace, sandbox, toolCalls, skills, and runnerTrace evidence"
longLivedSession: false,
reason: "completed read-only session-registry response includes session, workspace, sandbox, toolCalls, skills, runnerTrace, and a blocked long-lived Codex stdio gate"
};
}
@@ -240,9 +261,11 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
runnerTrace: null,
sessionMode: null,
sessionReuse: null,
session: null,
implementationType: null,
runnerLimitations: [],
codexStdioFeasibility: null
codexStdioFeasibility: null,
longLivedSessionGate: null
};
}
@@ -285,9 +308,11 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
runnerTrace: summarizeRunnerTrace(payload.runnerTrace),
sessionMode: stringOrNull(payload.sessionMode),
sessionReuse: summarizeSessionReuse(payload.sessionReuse),
session: summarizeSession(payload.session),
implementationType: stringOrNull(payload.implementationType),
runnerLimitations: summarizeStringList(payload.runnerLimitations),
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility)
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility),
longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate)
};
}
@@ -310,9 +335,11 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
runnerTrace: summarizeRunnerTrace(payload.runnerTrace),
sessionMode: stringOrNull(payload.sessionMode),
sessionReuse: summarizeSessionReuse(payload.sessionReuse),
session: summarizeSession(payload.session),
implementationType: stringOrNull(payload.implementationType),
runnerLimitations: summarizeStringList(payload.runnerLimitations),
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility)
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility),
longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate)
};
}
@@ -409,6 +436,9 @@ export function inspectRealCompletionEvidence(payload) {
if (provider === "openai-responses" && payload?.capabilityLevel && payload.capabilityLevel !== "text-chat-only") {
return { ok: false, reason: "openai-responses cannot claim runner capabilityLevel" };
}
if (provider === "codex-readonly-runner" && payload?.longLivedSessionGate?.status === "pass") {
return { ok: false, reason: "read-only runner cannot claim long-lived session gate pass" };
}
if (untrustedProviderPattern.test(`${provider} ${backend}`)) {
return { ok: false, reason: `provider/backend looks non-real: ${payload.provider}/${payload.backend}` };
}
@@ -521,7 +551,32 @@ function summarizeSessionReuse(value) {
reused: value.reused === true,
turn: typeof value.turn === "number" ? value.turn : null,
previousTurns: typeof value.previousTurns === "number" ? value.previousTurns : null,
workspace: stringOrNull(value.workspace)
workspace: stringOrNull(value.workspace),
status: stringOrNull(value.status),
idleTimeoutMs: typeof value.idleTimeoutMs === "number" ? value.idleTimeoutMs : null,
lastTraceId: stringOrNull(value.lastTraceId)
};
}
function summarizeSession(value) {
if (!value || typeof value !== "object") return null;
return {
sessionId: stringOrNull(value.sessionId),
conversationId: stringOrNull(value.conversationId),
status: stringOrNull(value.status),
workspace: stringOrNull(value.workspace),
sandbox: stringOrNull(value.sandbox),
runnerKind: stringOrNull(value.runnerKind),
capabilityLevel: stringOrNull(value.capabilityLevel),
implementationType: stringOrNull(value.implementationType),
createdAt: stringOrNull(value.createdAt),
updatedAt: stringOrNull(value.updatedAt),
idleTimeoutMs: typeof value.idleTimeoutMs === "number" ? value.idleTimeoutMs : null,
lastTraceId: stringOrNull(value.lastTraceId),
turn: typeof value.turn === "number" ? value.turn : null,
longLivedSession: value.longLivedSession === true,
codexStdio: value.codexStdio === true,
secretMaterialStored: value.secretMaterialStored === true
};
}
@@ -542,6 +597,26 @@ function summarizeCodexStdioFeasibility(value) {
};
}
function summarizeLongLivedSessionGate(value) {
if (!value || typeof value !== "object") return null;
return {
status: stringOrNull(value.status),
pass: value.pass === true,
requiredCapability: stringOrNull(value.requiredCapability),
currentCapability: stringOrNull(value.currentCapability),
sessionId: stringOrNull(value.sessionId),
sessionStatus: stringOrNull(value.sessionStatus),
runnerKind: stringOrNull(value.runnerKind),
provider: stringOrNull(value.provider),
blockers: Array.isArray(value.blockers)
? value.blockers.map((blocker) => ({
code: stringOrNull(blocker.code),
sourceIssue: stringOrNull(blocker.sourceIssue)
}))
: []
};
}
function summarizeStringList(value) {
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
}
@@ -2222,8 +2222,10 @@ function hasCodeAgentCompletedEvidenceVisibility({ app }) {
/recordField\("workspace",\s*message\.workspace\)/u.test(messageEvidenceBody) &&
/recordField\("sandbox",\s*message\.sandbox\)/u.test(messageEvidenceBody) &&
/recordField\("capability",\s*message\.capabilityLevel\)/u.test(messageEvidenceBody) &&
/recordField\("session",\s*sessionSummary\(message\.session\)\)/u.test(messageEvidenceBody) &&
/recordField\("sessionMode",\s*message\.sessionMode\)/u.test(messageEvidenceBody) &&
/recordField\("sessionReuse",\s*sessionReuseSummary\(message\.sessionReuse\)\)/u.test(messageEvidenceBody) &&
/recordField\("longLivedGate",\s*longLivedSessionGateSummary\(message\.longLivedSessionGate\)\)/u.test(messageEvidenceBody) &&
/recordField\("implementation",\s*message\.implementationType\)/u.test(messageEvidenceBody) &&
/recordField\("limitations",\s*limitationsSummary\(message\.runnerLimitations\)\)/u.test(messageEvidenceBody) &&
/recordField\("codexStdio",\s*codexStdioFeasibilitySummary\(message\.codexStdioFeasibility\)\)/u.test(messageEvidenceBody) &&
@@ -2238,6 +2240,9 @@ function hasCodeAgentCompletedEvidenceVisibility({ app }) {
/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) &&
/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) &&
+31 -2
View File
@@ -363,11 +363,13 @@ function initCommandBar() {
backend: result.backend,
workspace: result.workspace,
sandbox: result.sandbox,
session: result.session,
sessionMode: result.sessionMode,
sessionReuse: result.sessionReuse,
implementationType: result.implementationType,
runnerLimitations: result.runnerLimitations,
codexStdioFeasibility: result.codexStdioFeasibility,
longLivedSessionGate: result.longLivedSessionGate,
toolCalls: result.toolCalls,
skills: result.skills,
runner: result.runner,
@@ -2142,11 +2144,13 @@ function messageEvidenceFields(message) {
recordField("workspace", message.workspace),
recordField("sandbox", message.sandbox),
recordField("capability", message.capabilityLevel),
recordField("session", sessionSummary(message.session)),
recordField("sessionMode", message.sessionMode),
recordField("sessionReuse", sessionReuseSummary(message.sessionReuse)),
recordField("implementation", message.implementationType),
recordField("limitations", limitationsSummary(message.runnerLimitations)),
recordField("codexStdio", codexStdioFeasibilitySummary(message.codexStdioFeasibility)),
recordField("longLivedGate", longLivedSessionGateSummary(message.longLivedSessionGate)),
recordField("toolCalls", toolCallsSummary(message.toolCalls)),
recordField("skills", skillsSummary(message.skills)),
recordField("runnerTrace", runnerTraceSummary(message.runnerTrace)),
@@ -2197,7 +2201,19 @@ function runnerTraceSummary(runnerTrace) {
function sessionReuseSummary(sessionReuse) {
if (!sessionReuse || typeof sessionReuse !== "object") return null;
const state = sessionReuse.reused ? "reused" : "new";
return `${state}:turn${sessionReuse.turn ?? "?"}`;
const status = sessionReuse.status ? `:${sessionReuse.status}` : "";
return `${state}:turn${sessionReuse.turn ?? "?"}${status}`;
}
function sessionSummary(session) {
if (!session || typeof session !== "object") return null;
const parts = [
session.sessionId,
session.status,
typeof session.turn === "number" ? `turn${session.turn}` : null,
session.lastTraceId ? `lastTrace=${session.lastTraceId}` : null
].filter(Boolean);
return parts.join(":");
}
function limitationsSummary(limitations) {
@@ -2205,6 +2221,15 @@ function limitationsSummary(limitations) {
return limitations.slice(0, 3).join(",");
}
function longLivedSessionGateSummary(gate) {
if (!gate || typeof gate !== "object") return null;
const status = gate.status ?? "unknown";
const blockers = Array.isArray(gate.blockers)
? gate.blockers.map((blocker) => blocker?.code).filter(Boolean).slice(0, 2).join(",")
: "";
return blockers ? `${status}:${blockers}` : status;
}
function codexStdioFeasibilitySummary(feasibility) {
if (!feasibility || typeof feasibility !== "object") return null;
const blockers = Array.isArray(feasibility.blockers) ? feasibility.blockers.length : 0;
@@ -2491,7 +2516,10 @@ function isCodexRunnerCapableEvidence(value) {
return (
CODEX_RUNNER_CAPABLE_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase()) &&
value?.runner?.kind === "hwlab-readonly-runner" &&
value?.capabilityLevel === "read-only-tools" &&
(value?.capabilityLevel === "read-only-session-tools" || value?.capabilityLevel === "read-only-tools") &&
value?.session?.status === "idle" &&
typeof value?.session?.idleTimeoutMs === "number" &&
Boolean(value?.session?.lastTraceId) &&
value?.sessionMode === "controlled-readonly-session-registry" &&
value?.implementationType === "controlled-readonly-session-registry" &&
Boolean(value?.sessionReuse) &&
@@ -2504,6 +2532,7 @@ function isCodexRunnerCapableEvidence(value) {
(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)
);
+12
View File
@@ -496,24 +496,36 @@ assert.match(app, /Boolean\(value\?\.traceId\)/);
assert.match(app, /Boolean\(value\?\.conversationId \|\| value\?\.sessionId\)/);
assert.match(app, /value\?\.sessionMode === "controlled-readonly-session-registry"/);
assert.match(app, /value\?\.implementationType === "controlled-readonly-session-registry"/);
assert.match(app, /value\?\.capabilityLevel === "read-only-session-tools"/);
assert.match(app, /value\?\.session\?\.status === "idle"/);
assert.match(app, /typeof value\?\.session\?\.idleTimeoutMs === "number"/);
assert.match(app, /Boolean\(value\?\.session\?\.lastTraceId\)/);
assert.match(app, /value\?\.longLivedSessionGate\?\.status === "blocked"/);
assert.match(app, /Boolean\(value\?\.sessionReuse\)/);
assert.match(app, /value\?\.runner\?\.codexStdio === false/);
assert.match(app, /value\?\.runner\?\.writeCapable === false/);
assert.match(app, /value\?\.runner\?\.durableSession === false/);
assert.match(app, /providerTrace:\s*result\.providerTrace/);
assert.match(app, /session:\s*result\.session/);
assert.match(app, /sessionMode:\s*result\.sessionMode/);
assert.match(app, /sessionReuse:\s*result\.sessionReuse/);
assert.match(app, /implementationType:\s*result\.implementationType/);
assert.match(app, /runnerLimitations:\s*result\.runnerLimitations/);
assert.match(app, /codexStdioFeasibility:\s*result\.codexStdioFeasibility/);
assert.match(app, /longLivedSessionGate:\s*result\.longLivedSessionGate/);
assert.match(app, /providerTraceSummary\(message\.providerTrace\)/);
assert.match(app, /recordField\("conversation", message\.conversationId\)/);
assert.match(app, /recordField\("provider", message\.provider\)/);
assert.match(app, /recordField\("session", sessionSummary\(message\.session\)\)/);
assert.match(app, /recordField\("sessionMode", message\.sessionMode\)/);
assert.match(app, /recordField\("sessionReuse", sessionReuseSummary\(message\.sessionReuse\)\)/);
assert.match(app, /recordField\("implementation", message\.implementationType\)/);
assert.match(app, /recordField\("limitations", limitationsSummary\(message\.runnerLimitations\)\)/);
assert.match(app, /recordField\("codexStdio", codexStdioFeasibilitySummary\(message\.codexStdioFeasibility\)\)/);
assert.match(app, /recordField\("longLivedGate", longLivedSessionGateSummary\(message\.longLivedSessionGate\)\)/);
assert.match(app, /function sessionSummary/);
assert.match(app, /function longLivedSessionGateSummary/);
assert.match(app, /read-only-session-tools/);
assert.match(app, /controlled-readonly-session-registry/);
assert.match(app, /not-codex-stdio/);
assert.match(app, /not-write-capable/);