Merge pull request #312 from pikasTech/fix/code-agent-controlled-session-runner-275

feat: add controlled Code Agent session registry
This commit is contained in:
Lyon
2026-05-23 17:24:37 +08:00
committed by GitHub
8 changed files with 1136 additions and 65 deletions
@@ -38,6 +38,8 @@ 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。 |
| `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。 |
| 传输失败、schema 不完整、HTTP 非预期、`reply.content` 为空或缺失 | `BLOCKED`,按 runtime/schema/transport 分析。 |
@@ -45,6 +47,24 @@ report、issue、PR 或截图。
只有“真实 DEV 路由 + `completed` + 非空 assistant reply”能作为 DEV-LIVE 回复通过依据。
不得把 mock、fixture、本地 echo、source report、静态检查或前端状态当作通过。
## Runner 能力边界
`/v1/agent/chat` 可以先落地受控只读能力,但必须诚实区分:
- `controlled-readonly-session-registry`:由 cloud-api 进程内 registry 保存
`conversationId/sessionId` 映射、`turn` 计数、workspace 与只读工具 trace。它可以覆盖
`pwd``skills.discover``ls``rg --files` 和 bounded `cat`,输出必须限长和脱敏。
- 该模式必须同时标记 `not-codex-stdio``not-write-capable``not-durable-session`。它不是
long-lived Codex stdio session,不提供写文件、任意 shell、硬件写、Secret/kubeconfig/DB URL
读取,也不证明 M3/M4/M5 trusted green。
- OpenAI Responses fallback 只能标记为 `openai-responses-fallback` /
`text-chat-only`,不得满足 Codex runner capability gate。
当前 DEV/runtime 若要升级为完整 #275 runner,至少需要 repo-owned 的 Codex CLI/stdio 或等价
runner 二进制/协议适配、session supervisor 生命周期、workspace mount 与 sandbox 合同、token/Secret
注入边界、trace/cancel/reap 机制,以及与 cloud-api/workbench 的持久 session 映射。缺任一项时,必须
`codexStdioFeasibility` 中报告 blocker。
## Smoke Gate
本地合同检查:
File diff suppressed because it is too large Load Diff
+112 -2
View File
@@ -983,11 +983,22 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence
assert.equal(payload.sandbox, "read-only");
assert.equal(payload.runner.kind, "hwlab-readonly-runner");
assert.equal(payload.runner.longLivedSession, false);
assert.equal(payload.runner.codexStdio, false);
assert.equal(payload.runner.writeCapable, false);
assert.equal(payload.runner.durableSession, false);
assert.equal(payload.runner.session, "controlled-readonly-session-registry");
assert.equal(payload.sessionMode, "controlled-readonly-session-registry");
assert.equal(payload.implementationType, "controlled-readonly-session-registry");
assert.equal(payload.sessionReuse.reused, false);
assert.equal(payload.sessionReuse.turn, 1);
assert.ok(payload.runnerLimitations.includes("not-codex-stdio"));
assert.ok(payload.codexStdioFeasibility.blockers.some((blocker) => blocker.code === "runner_lifecycle_missing"));
assert.equal(payload.toolCalls[0].name, "pwd");
assert.equal(payload.toolCalls[0].status, "completed");
assert.equal(payload.toolCalls[0].stdout, workspace);
assert.equal(payload.skills.status, "not_requested");
assert.equal(payload.runnerTrace.runnerKind, "hwlab-readonly-runner");
assert.equal(payload.runnerTrace.sessionMode, "controlled-readonly-session-registry");
assert.match(payload.reply.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")));
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
} finally {
@@ -997,6 +1008,92 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence
}
});
test("cloud api /v1/agent/chat reuses controlled read-only session and exposes bounded file tools", async () => {
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-tools-"));
await writeFile(path.join(workspace, "alpha.txt"), "alpha\nbeta\n", "utf8");
await writeFile(path.join(workspace, "package.json"), "{\"name\":\"sample\"}\n", "utf8");
await mkdir(path.join(workspace, "src"), { recursive: true });
await writeFile(path.join(workspace, "src", "main.mjs"), "export const value = 1;\n", "utf8");
const env = {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: workspace
};
const server = createCloudApiServer({ env });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const first = await postAgent(port, {
conversationId: "cnv_server-test-session-reuse",
traceId: "trc_server-test-session-reuse-ls",
message: "ls ."
});
assert.equal(first.status, "completed");
assert.equal(first.sessionReuse.reused, false);
assert.equal(first.sessionReuse.turn, 1);
assert.equal(first.toolCalls[0].name, "ls");
assert.match(first.toolCalls[0].stdout, /alpha\.txt/u);
const second = await postAgent(port, {
conversationId: "cnv_server-test-session-reuse",
traceId: "trc_server-test-session-reuse-cat",
message: "cat alpha.txt"
});
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, "cat");
assert.equal(second.toolCalls[0].stdout, "alpha\nbeta\n");
const third = await postAgent(port, {
conversationId: "cnv_server-test-session-reuse",
traceId: "trc_server-test-session-reuse-rg",
message: "rg --files ."
});
assert.equal(third.status, "completed");
assert.equal(third.sessionReuse.reused, true);
assert.equal(third.sessionReuse.turn, 3);
assert.equal(third.toolCalls[0].name, "rg --files");
assert.match(third.toolCalls[0].stdout, /src\/main\.mjs/u);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api /v1/agent/chat blocks forbidden file paths without leaking values", async () => {
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-block-"));
const server = createCloudApiServer({
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: workspace
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const payload = await postAgent(port, {
conversationId: "cnv_server-test-security-block",
traceId: "trc_server-test-security-block",
message: "cat ../outside.txt"
});
assert.equal(payload.status, "failed");
assert.equal(payload.error.code, "security_blocked");
assert.equal(payload.toolCalls[0].name, "cat");
assert.equal(payload.toolCalls[0].status, "blocked");
assert.equal(payload.capabilityLevel, "blocked");
assert.equal(Object.hasOwn(payload, "reply"), false);
assert.equal(JSON.stringify(payload).includes("sk-"), false);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api /v1/agent/chat discovers skills manifest with source and version evidence", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-skills-"));
const skillsDir = path.join(root, "skills");
@@ -1115,7 +1212,7 @@ test("cloud api /v1/agent/chat reports unsupported read-only tool as blocked", a
},
body: JSON.stringify({
conversationId: "cnv_server-test-tool-unavailable",
message: "请用cat读取package.json"
message: "请用grep搜索 package.json"
})
});
assert.equal(response.status, 200);
@@ -1123,7 +1220,7 @@ test("cloud api /v1/agent/chat reports unsupported read-only tool as blocked", a
assert.equal(payload.status, "failed");
assert.equal(payload.error.code, "tool_unavailable");
assert.equal(payload.capabilityLevel, "blocked");
assert.equal(payload.toolCalls[0].name, "cat");
assert.equal(payload.toolCalls[0].name, "grep");
assert.equal(payload.toolCalls[0].status, "blocked");
assert.equal(Object.hasOwn(payload, "reply"), false);
} finally {
@@ -1605,3 +1702,16 @@ test("cloud api /v1/m3/io returns structured blocked payload when gateway is una
});
}
});
async function postAgent(port, body) {
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
method: "POST",
headers: {
"content-type": "application/json",
...(body.traceId ? { "x-trace-id": body.traceId } : {})
},
body: JSON.stringify(body)
});
assert.equal(response.status, 200);
return response.json();
}
+113 -3
View File
@@ -180,6 +180,16 @@ async function runLocalContractSmoke() {
assert.equal(runnerPwd.workspace, process.cwd());
assert.equal(runnerPwd.sandbox, "read-only");
assert.equal(runnerPwd.capabilityLevel, "read-only-tools");
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");
assert.equal(runnerPwd.runner.codexStdio, false);
assert.equal(runnerPwd.runner.writeCapable, false);
assert.equal(runnerPwd.runner.durableSession, false);
assert.equal(runnerPwd.sessionReuse.reused, false);
assert.equal(runnerPwd.sessionReuse.turn, 1);
assert.ok(runnerPwd.runnerLimitations.includes("not-codex-stdio"));
assert.equal(runnerPwd.codexStdioFeasibility.status, "blocked");
assert.equal(runnerPwd.toolCalls[0].name, "pwd");
assert.equal(runnerPwd.toolCalls[0].status, "completed");
const runnerCapability = classifyCodexRunnerCapability(runnerPwd, { httpStatus: 200 });
@@ -187,6 +197,30 @@ async function runLocalContractSmoke() {
assert.equal(runnerCapability.capabilityPass, true);
logOk("read-only runner pwd capability");
const runnerSecondTurn = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-runner-pwd",
traceId: "trc_code-agent-chat-runner-session-reuse",
message: "ls ."
},
{
now: () => "2026-05-22T00:02:01.000Z",
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
validateCodeAgentChatSchema(runnerSecondTurn);
assert.equal(runnerSecondTurn.status, "completed");
assert.equal(runnerSecondTurn.sessionId, runnerPwd.sessionId);
assert.equal(runnerSecondTurn.sessionReuse.reused, true);
assert.equal(runnerSecondTurn.sessionReuse.turn, 2);
assert.equal(runnerSecondTurn.toolCalls[0].name, "ls");
assert.equal(runnerSecondTurn.toolCalls[0].status, "completed");
assert.match(runnerSecondTurn.reply.content, /package\.json/u);
logOk("same conversation reuses controlled read-only session registry");
const fallbackCapability = classifyCodexRunnerCapability(completedLivePayload, { httpStatus: 200 });
assert.equal(fallbackCapability.status, "blocked");
assert.equal(fallbackCapability.blocker, "openai-fallback-not-runner");
@@ -218,10 +252,10 @@ async function runLocalContractSmoke() {
assert.equal(classifyCodexRunnerCapability(missingSkills, { httpStatus: 200 }).capabilityPass, false);
logOk("missing skills is structured blocker");
const toolUnavailable = await handleCodeAgentChat(
const boundedCat = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-tool-unavailable",
traceId: "trc_code-agent-chat-tool-unavailable",
conversationId: "cnv_code-agent-chat-cat",
traceId: "trc_code-agent-chat-cat",
message: "请用cat读取 package.json"
},
{
@@ -232,6 +266,49 @@ async function runLocalContractSmoke() {
}
}
);
validateCodeAgentChatSchema(boundedCat);
assert.equal(boundedCat.status, "completed");
assert.equal(boundedCat.toolCalls[0].name, "cat");
assert.equal(boundedCat.toolCalls[0].status, "completed");
assert.ok(boundedCat.toolCalls[0].stdout.length <= 4100);
assert.match(boundedCat.reply.content, /"name": "hwlab"/u);
logOk("bounded cat read-only tool capability");
const boundedRgFiles = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-rg-files",
traceId: "trc_code-agent-chat-rg-files",
message: "rg --files ."
},
{
now: () => "2026-05-22T00:04:30.000Z",
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
validateCodeAgentChatSchema(boundedRgFiles);
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);
logOk("bounded rg --files read-only tool capability");
const toolUnavailable = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-tool-unavailable",
traceId: "trc_code-agent-chat-tool-unavailable",
message: "请用grep搜索 package.json"
},
{
now: () => "2026-05-22T00:04:40.000Z",
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
validateCodeAgentChatSchema(toolUnavailable);
assert.equal(toolUnavailable.status, "failed");
assert.equal(toolUnavailable.error.code, "tool_unavailable");
@@ -239,6 +316,27 @@ async function runLocalContractSmoke() {
assert.equal(classifyCodexRunnerCapability(toolUnavailable, { httpStatus: 200 }).capabilityPass, false);
logOk("tool unavailable is blocked, not completed runner capability");
const securityBlocked = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-security-blocked",
traceId: "trc_code-agent-chat-security-blocked",
message: "请cat ../secret"
},
{
now: () => "2026-05-22T00:04:50.000Z",
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
validateCodeAgentChatSchema(securityBlocked);
assert.equal(securityBlocked.status, "failed");
assert.equal(securityBlocked.error.code, "security_blocked");
assert.equal(securityBlocked.toolCalls[0].status, "blocked");
assert.equal(classifyCodexRunnerCapability(securityBlocked, { httpStatus: 200 }).capabilityPass, false);
logOk("security blocked path is structured");
const completedHttp200Readiness = classifyCodeAgentChatReadiness(completedLivePayload, { realDevLive: true, httpStatus: 200 });
assert.equal(completedHttp200Readiness.status, "pass");
assert.equal(completedHttp200Readiness.devLiveReplyPass, true);
@@ -333,6 +431,18 @@ function summarizePayload(payload, options = {}) {
provider: responseSummary?.provider ?? null,
model: responseSummary?.model ?? null,
backend: responseSummary?.backend ?? null,
runner: responseSummary?.runner ?? null,
workspace: responseSummary?.workspace ?? null,
sandbox: responseSummary?.sandbox ?? null,
capabilityLevel: responseSummary?.capabilityLevel ?? null,
sessionMode: responseSummary?.sessionMode ?? null,
sessionReuse: responseSummary?.sessionReuse ?? null,
implementationType: responseSummary?.implementationType ?? null,
runnerLimitations: responseSummary?.runnerLimitations ?? [],
toolCalls: responseSummary?.toolCalls ?? [],
skills: responseSummary?.skills ?? null,
runnerTrace: responseSummary?.runnerTrace ?? null,
codexStdioFeasibility: responseSummary?.codexStdioFeasibility ?? null,
assistantReplyNonEmpty: responseSummary?.hasReply === true,
assistantReplyLength: assistantReply.length,
error: responseSummary?.error ?? null
+80 -4
View File
@@ -78,6 +78,14 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {
const toolCalls = Array.isArray(payload?.toolCalls) ? payload.toolCalls : [];
const runnerTrace = payload?.runnerTrace && typeof payload.runnerTrace === "object" ? payload.runnerTrace : null;
const skills = payload?.skills && typeof payload.skills === "object" ? payload.skills : null;
const sessionMode = stringOrNull(payload?.sessionMode ?? payload?.runner?.sessionMode ?? payload?.runner?.session);
const implementationType = stringOrNull(payload?.implementationType ?? payload?.runner?.implementationType);
const runnerLimitations = Array.isArray(payload?.runnerLimitations)
? payload.runnerLimitations.filter((item) => typeof item === "string")
: Array.isArray(payload?.runner?.runnerLimitations)
? payload.runner.runnerLimitations.filter((item) => typeof item === "string")
: [];
const sessionReuse = payload?.sessionReuse && typeof payload.sessionReuse === "object" ? payload.sessionReuse : null;
if (provider === "openai-responses" || runnerKind === "openai-responses-fallback") {
return {
@@ -96,9 +104,16 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {
if (capabilityLevel !== "read-only-tools") missing.push("capabilityLevel=read-only-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 (!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 (missing.length > 0) {
return {
@@ -222,7 +237,12 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
capabilityLevel: null,
toolCalls: [],
skills: null,
runnerTrace: null
runnerTrace: null,
sessionMode: null,
sessionReuse: null,
implementationType: null,
runnerLimitations: [],
codexStdioFeasibility: null
};
}
@@ -262,7 +282,12 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
capabilityLevel: stringOrNull(payload.capabilityLevel),
toolCalls: summarizeToolCalls(payload.toolCalls),
skills: summarizeSkills(payload.skills),
runnerTrace: summarizeRunnerTrace(payload.runnerTrace)
runnerTrace: summarizeRunnerTrace(payload.runnerTrace),
sessionMode: stringOrNull(payload.sessionMode),
sessionReuse: summarizeSessionReuse(payload.sessionReuse),
implementationType: stringOrNull(payload.implementationType),
runnerLimitations: summarizeStringList(payload.runnerLimitations),
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility)
};
}
@@ -282,7 +307,12 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
capabilityLevel: stringOrNull(payload.capabilityLevel),
toolCalls: summarizeToolCalls(payload.toolCalls),
skills: summarizeSkills(payload.skills),
runnerTrace: summarizeRunnerTrace(payload.runnerTrace)
runnerTrace: summarizeRunnerTrace(payload.runnerTrace),
sessionMode: stringOrNull(payload.sessionMode),
sessionReuse: summarizeSessionReuse(payload.sessionReuse),
implementationType: stringOrNull(payload.implementationType),
runnerLimitations: summarizeStringList(payload.runnerLimitations),
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility)
};
}
@@ -429,8 +459,16 @@ function summarizeRunner(payload) {
workspace: stringOrNull(payload.runner.workspace),
sandbox: stringOrNull(payload.runner.sandbox),
session: stringOrNull(payload.runner.session),
sessionMode: stringOrNull(payload.runner.sessionMode),
sessionId: stringOrNull(payload.runner.sessionId),
implementationType: stringOrNull(payload.runner.implementationType),
codexStdio: payload.runner.codexStdio === true,
writeCapable: payload.runner.writeCapable === true,
durableSession: payload.runner.durableSession === true,
longLivedSession: payload.runner.longLivedSession === true,
readOnly: payload.runner.readOnly === true,
capabilityLevel: stringOrNull(payload.runner.capabilityLevel)
capabilityLevel: stringOrNull(payload.runner.capabilityLevel),
runnerLimitations: summarizeStringList(payload.runner.runnerLimitations)
};
}
@@ -465,11 +503,49 @@ function summarizeRunnerTrace(value) {
return {
runnerKind: stringOrNull(value.runnerKind),
sandbox: stringOrNull(value.sandbox),
sessionMode: stringOrNull(value.sessionMode),
implementationType: stringOrNull(value.implementationType),
sessionReused: value.sessionReused === true,
turn: typeof value.turn === "number" ? value.turn : null,
outputTruncated: value.outputTruncated === true,
valuesPrinted: value.valuesPrinted === true
};
}
function summarizeSessionReuse(value) {
if (!value || typeof value !== "object") return null;
return {
conversationId: stringOrNull(value.conversationId),
sessionId: stringOrNull(value.sessionId),
mapped: value.mapped === true,
reused: value.reused === true,
turn: typeof value.turn === "number" ? value.turn : null,
previousTurns: typeof value.previousTurns === "number" ? value.previousTurns : null,
workspace: stringOrNull(value.workspace)
};
}
function summarizeCodexStdioFeasibility(value) {
if (!value || typeof value !== "object") return null;
return {
status: stringOrNull(value.status),
canStartLongLivedCodexStdio: value.canStartLongLivedCodexStdio === true,
currentImplementation: stringOrNull(value.currentImplementation),
implementationRequired: stringOrNull(value.implementationRequired),
binaryOnPath: value.binaryOnPath === true,
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") : [];
}
function stringOrNull(value) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
@@ -173,6 +173,11 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([
"workspace",
"sandbox",
"capability",
"sessionMode",
"sessionReuse",
"implementation",
"limitations",
"codexStdio",
"toolCalls",
"skills",
"runnerTrace",
@@ -182,6 +187,10 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([
"providerTrace",
"openai-responses",
"codex-cli",
"controlled-readonly-session-registry",
"not-codex-stdio",
"not-write-capable",
"not-durable-session",
"echo/mock/stub",
"untrusted_completion",
"SOURCE fixture",
@@ -2145,6 +2154,7 @@ function hasCodeAgentReadinessVisibility({ html, app }) {
function hasCodeAgentCompletedEvidenceVisibility({ app }) {
const messageEvidenceBody = functionBody(app, "messageEvidenceFields");
const realEvidenceBody = functionBody(app, "hasRealCodeAgentEvidence");
const runnerEvidenceBody = functionBody(app, "isCodexRunnerCapableEvidence");
return (
requiredCodeAgentEvidenceTerms.every((term) => app.includes(term)) &&
/conversationId:\s*result\.conversationId \|\| result\.sessionId \|\| state\.conversationId/u.test(app) &&
@@ -2157,6 +2167,11 @@ 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\("sessionMode",\s*message\.sessionMode\)/u.test(messageEvidenceBody) &&
/recordField\("sessionReuse",\s*sessionReuseSummary\(message\.sessionReuse\)\)/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) &&
/recordField\("toolCalls",\s*toolCallsSummary\(message\.toolCalls\)\)/u.test(messageEvidenceBody) &&
/recordField\("skills",\s*skillsSummary\(message\.skills\)\)/u.test(messageEvidenceBody) &&
/recordField\("runnerTrace",\s*runnerTraceSummary\(message\.runnerTrace\)\)/u.test(messageEvidenceBody) &&
@@ -2167,6 +2182,11 @@ 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) &&
/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) &&
/TRUSTED_CODE_AGENT_PROVIDERS/u.test(app) &&
/UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN/u.test(realEvidenceBody) &&
/value\?\.conversationId \|\| value\?\.sessionId/u.test(realEvidenceBody) &&
+52 -4
View File
@@ -106,6 +106,7 @@ const el = {
const state = {
conversationId: null,
sessionId: null,
codeAgentAvailability: null,
chatMessages: [],
chatPending: false,
@@ -335,6 +336,7 @@ function initCommandBar() {
try {
const result = await sendAgentMessage(value, state.conversationId, traceId);
state.conversationId = result.conversationId || result.sessionId || state.conversationId;
state.sessionId = result.sessionId || state.sessionId || state.conversationId;
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
const completion = classifyCodeAgentCompletion(result);
const status = completion.status;
@@ -355,6 +357,11 @@ function initCommandBar() {
backend: result.backend,
workspace: result.workspace,
sandbox: result.sandbox,
sessionMode: result.sessionMode,
sessionReuse: result.sessionReuse,
implementationType: result.implementationType,
runnerLimitations: result.runnerLimitations,
codexStdioFeasibility: result.codexStdioFeasibility,
toolCalls: result.toolCalls,
skills: result.skills,
runner: result.runner,
@@ -406,6 +413,7 @@ function initCommandBar() {
el.commandClear.addEventListener("click", () => {
state.chatMessages = [];
state.conversationId = null;
state.sessionId = null;
state.chatPending = false;
el.commandInput.value = "";
renderAgentChatStatus("idle");
@@ -453,6 +461,7 @@ function renderProbePending() {
}
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc")) {
const sessionId = state.conversationId === conversationId ? state.sessionId : undefined;
const response = await fetchJson("/v1/agent/chat", {
method: "POST",
headers: {
@@ -464,6 +473,7 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI
body: JSON.stringify({
message,
conversationId,
sessionId,
traceId,
projectId: gateSummary.topology.projectId
})
@@ -2152,6 +2162,11 @@ function messageEvidenceFields(message) {
recordField("workspace", message.workspace),
recordField("sandbox", message.sandbox),
recordField("capability", message.capabilityLevel),
recordField("sessionMode", message.sessionMode),
recordField("sessionReuse", sessionReuseSummary(message.sessionReuse)),
recordField("implementation", message.implementationType),
recordField("limitations", limitationsSummary(message.runnerLimitations)),
recordField("codexStdio", codexStdioFeasibilitySummary(message.codexStdioFeasibility)),
recordField("toolCalls", toolCallsSummary(message.toolCalls)),
recordField("skills", skillsSummary(message.skills)),
recordField("runnerTrace", runnerTraceSummary(message.runnerTrace)),
@@ -2191,7 +2206,29 @@ function skillsSummary(skills) {
function runnerTraceSummary(runnerTrace) {
if (!runnerTrace || typeof runnerTrace !== "object") return null;
return runnerTrace.runnerKind ?? "present";
const parts = [
runnerTrace.runnerKind ?? "present",
runnerTrace.sessionMode,
typeof runnerTrace.turn === "number" ? `turn${runnerTrace.turn}` : null
].filter(Boolean);
return parts.join(":");
}
function sessionReuseSummary(sessionReuse) {
if (!sessionReuse || typeof sessionReuse !== "object") return null;
const state = sessionReuse.reused ? "reused" : "new";
return `${state}:turn${sessionReuse.turn ?? "?"}`;
}
function limitationsSummary(limitations) {
if (!Array.isArray(limitations) || limitations.length === 0) return null;
return limitations.slice(0, 3).join(",");
}
function codexStdioFeasibilitySummary(feasibility) {
if (!feasibility || typeof feasibility !== "object") return null;
const blockers = Array.isArray(feasibility.blockers) ? feasibility.blockers.length : 0;
return `${feasibility.status ?? "unknown"}:${blockers}`;
}
function statusCard(item) {
@@ -2297,6 +2334,9 @@ function isBlockedAgentResponse(payload, httpStatus) {
isHttpNon2xxStatus(payload.error?.providerStatus) ||
payload.status === "blocked" ||
payload.error?.code === "provider_unavailable" ||
payload.error?.code === "security_blocked" ||
payload.error?.code === "tool_unavailable" ||
payload.error?.code === "skills_unavailable" ||
payload.availability?.status === "blocked"
)
);
@@ -2348,8 +2388,8 @@ function codeAgentStatusMessage(availability) {
if (availability?.runner?.ready === true) {
return {
role: "system",
title: "Code Agent 状态:只读 runner 已接入",
text: "pwdskills discovery 会走受控只读 runner普通中文对话可走 OpenAI Responses fallback,但 fallback 不算 Codex runner 能力。",
title: "Code Agent 状态:受控只读 session registry 已接入",
text: "pwdskills、ls、rg --files 和 cat 会走 controlled-readonly-session-registry;这是 not-codex-stdio、not-write-capable、not-durable-session 的安全增量,普通中文对话可走 OpenAI Responses fallback。",
status: "source"
};
}
@@ -2422,7 +2462,7 @@ function untrustedCompletionMessage(result) {
function codeAgentAvailabilitySummary() {
if (state.codeAgentAvailability?.runner?.ready === true) {
return "同源 API 可响应;pwd/skills 可走只读 runner,普通中文对话可降级到文本 fallback。";
return "同源 API 可响应;pwd/skills/ls/rg/cat 可走受控只读 session registry,但它不是 Codex stdio、不可写、非持久 session。";
}
if (state.codeAgentAvailability?.status === "blocked") {
return "同源 API 可响应;Code Agent 服务暂不可用,工作台保持只读和可重试。";
@@ -2480,6 +2520,14 @@ function isCodexRunnerCapableEvidence(value) {
CODEX_RUNNER_CAPABLE_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase()) &&
value?.runner?.kind === "hwlab-readonly-runner" &&
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) &&
+20
View File
@@ -469,10 +469,30 @@ assert.match(app, /Boolean\(value\?\.model\)/);
assert.match(app, /Boolean\(value\?\.backend\)/);
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, /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, /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, /providerTraceSummary\(message\.providerTrace\)/);
assert.match(app, /recordField\("conversation", message\.conversationId\)/);
assert.match(app, /recordField\("provider", message\.provider\)/);
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, /controlled-readonly-session-registry/);
assert.match(app, /not-codex-stdio/);
assert.match(app, /not-write-capable/);
assert.match(app, /not-durable-session/);
assert.match(app, /Code Agent 完成证据不足/);
assert.match(app, /本次不会标记为真实完成/);
assert.match(app, /untrusted_completion/);