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:
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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: "pwd 和 skills discovery 会走受控只读 runner;普通中文对话可走 OpenAI Responses fallback,但 fallback 不算 Codex runner 能力。",
|
||||
title: "Code Agent 状态:受控只读 session registry 已接入",
|
||||
text: "pwd、skills、ls、rg --files 和 cat 会走 controlled-readonly-session-registry;这是 not-codex-stdio、not-write-capable、not-durable-session 的安全增量,普通中文对话可走 OpenAI Responses fallback。",
|
||||
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) &&
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user