feat: add read-only Code Agent runner

This commit is contained in:
Code Queue Review
2026-05-23 08:13:07 +00:00
parent 78f2ab242e
commit db07268d6b
7 changed files with 1231 additions and 41 deletions
+86 -4
View File
@@ -8,6 +8,7 @@ import {
validateCodeAgentChatSchema
} from "../internal/cloud/code-agent-chat.mjs";
import {
classifyCodexRunnerCapability,
classifyCodeAgentChatReadiness,
isHttpNon2xxStatus,
summarizeCodeAgentPayload
@@ -116,10 +117,11 @@ async function runLocalContractSmoke() {
assert.match(failed.error.message, /Codex CLI command is not available/);
assert.deepEqual(failed.error.missingCommands, ["codex"]);
assert.ok(failed.error.missingEnv.includes("OPENAI_API_KEY"));
assert.equal(failed.availability.status, "blocked");
assert.match(failed.availability.blocker, /凭证缺口|OPENAI_API_KEY/u);
assert.equal(failed.availability.reason, "provider_unavailable");
assert.match(failed.availability.summary, /真实后端已接入/u);
assert.equal(failed.availability.status, "available");
assert.equal(failed.availability.blocker, null);
assert.equal(failed.availability.reason, null);
assert.equal(failed.availability.runner.ready, true);
assert.match(failed.availability.summary, /受控只读 runner/u);
assert.match(failed.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u);
assert.equal(JSON.stringify(failed).includes("sk-"), false);
assert.equal(Object.hasOwn(failed, "reply"), false);
@@ -157,6 +159,86 @@ async function runLocalContractSmoke() {
model: "gpt-5.5",
backend: "hwlab-cloud-api/openai-responses"
};
const runnerPwd = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-runner-pwd",
traceId: "trc_code-agent-chat-runner-pwd",
message: "用pwd列出你当前的工作目录"
},
{
now: () => "2026-05-22T00:02:00.000Z",
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
validateCodeAgentChatSchema(runnerPwd);
assert.equal(runnerPwd.status, "completed");
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.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);
logOk("read-only runner pwd capability");
const fallbackCapability = classifyCodexRunnerCapability(completedLivePayload, { httpStatus: 200 });
assert.equal(fallbackCapability.status, "blocked");
assert.equal(fallbackCapability.blocker, "openai-fallback-not-runner");
assert.equal(fallbackCapability.capabilityPass, false);
logOk("OpenAI fallback does not satisfy Codex runner capability");
const missingSkills = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-missing-skills",
traceId: "trc_code-agent-chat-missing-skills",
message: "列出你可用的skills"
},
{
now: () => "2026-05-22T00:03:00.000Z",
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
HWLAB_CODE_AGENT_SKILLS_DIRS: "/tmp/hwlab-code-agent-smoke-missing-skills"
},
skillsDirs: ["/tmp/hwlab-code-agent-smoke-missing-skills"],
skillsDirsExact: true
}
);
validateCodeAgentChatSchema(missingSkills);
assert.equal(missingSkills.status, "failed");
assert.equal(missingSkills.error.code, "skills_unavailable");
assert.equal(missingSkills.skills.status, "blocked");
assert.equal(missingSkills.skills.blockers[0].sourceIssue, "pikasTech/HWLAB#136");
assert.equal(classifyCodexRunnerCapability(missingSkills, { httpStatus: 200 }).capabilityPass, false);
logOk("missing skills is structured blocker");
const toolUnavailable = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-tool-unavailable",
traceId: "trc_code-agent-chat-tool-unavailable",
message: "请用cat读取 package.json"
},
{
now: () => "2026-05-22T00:04:00.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");
assert.equal(toolUnavailable.toolCalls[0].status, "blocked");
assert.equal(classifyCodexRunnerCapability(toolUnavailable, { httpStatus: 200 }).capabilityPass, false);
logOk("tool unavailable is blocked, not completed runner capability");
const completedHttp200Readiness = classifyCodeAgentChatReadiness(completedLivePayload, { realDevLive: true, httpStatus: 200 });
assert.equal(completedHttp200Readiness.status, "pass");
assert.equal(completedHttp200Readiness.devLiveReplyPass, true);
+150 -4
View File
@@ -1,4 +1,5 @@
const trustedLiveProviders = new Set(["openai-responses", "codex-cli"]);
const trustedLiveProviders = new Set(["openai-responses", "codex-cli", "codex-readonly-runner"]);
const trustedRunnerProviders = new Set(["codex-readonly-runner"]);
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;
@@ -56,6 +57,78 @@ export function classifyCodeAgentChatReadiness(payload, { realDevLive = false, h
};
}
export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {}) {
const providerBlock = classifyCodeAgentProviderBlock(payload, { httpStatus });
if (providerBlock.blocked) {
return {
status: "blocked",
level: providerBlock.level,
blocker: providerBlock.blocker,
capabilityPass: false,
reason: providerBlock.reason,
...(providerBlock.providerStatus != null ? { providerStatus: providerBlock.providerStatus } : {})
};
}
const provider = stringOrNull(payload?.provider);
const runnerKind = stringOrNull(payload?.runner?.kind);
const capabilityLevel = stringOrNull(payload?.capabilityLevel);
const workspace = stringOrNull(payload?.workspace ?? payload?.runner?.workspace);
const sandbox = stringOrNull(payload?.sandbox ?? payload?.runner?.sandbox);
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;
if (provider === "openai-responses" || runnerKind === "openai-responses-fallback") {
return {
status: "blocked",
level: "BLOCKED/text-chat-only",
blocker: "openai-fallback-not-runner",
capabilityPass: false,
reason: "OpenAI Responses fallback can answer text, but it has no workspace/tools/skills runner evidence."
};
}
const missing = [];
if (payload?.status !== "completed") missing.push("completed status");
if (!trustedRunnerProviders.has(provider)) missing.push("runner provider");
if (runnerKind !== "hwlab-readonly-runner") missing.push("runner.kind");
if (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 (toolCalls.length === 0) missing.push("toolCalls");
if (!runnerTrace) missing.push("runnerTrace");
if (!skills) missing.push("skills");
if (missing.length > 0) {
return {
status: "blocked",
level: "BLOCKED/runner-capability",
blocker: "runner-evidence-missing",
capabilityPass: false,
reason: `Codex runner capability evidence is incomplete: ${missing.join(", ")}`
};
}
if (toolCalls.some((tool) => tool.status !== "completed")) {
return {
status: "blocked",
level: "BLOCKED/tool",
blocker: "tool-unavailable",
capabilityPass: false,
reason: "At least one runner tool call is not completed."
};
}
return {
status: "pass",
level: "#275 read-only Codex runner capability pass",
blocker: null,
capabilityPass: true,
reason: "completed read-only runner response includes workspace, sandbox, toolCalls, skills, and runnerTrace evidence"
};
}
export function classifyCodeAgentProviderBlock(payload, { httpStatus = null } = {}) {
const payloadStatus = stringOrNull(payload?.status);
const errorCode = stringOrNull(payload?.error?.code);
@@ -142,7 +215,14 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
missingEnv: [],
missingCommands: [],
providerStatus: providerBlock.providerStatus
}
},
runner: null,
workspace: null,
sandbox: null,
capabilityLevel: null,
toolCalls: [],
skills: null,
runnerTrace: null
};
}
@@ -175,7 +255,14 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
backend,
traceId: observedTraceId,
hasReply: false,
error: blockedError
error: blockedError,
runner: summarizeRunner(payload),
workspace: stringOrNull(payload.workspace),
sandbox: stringOrNull(payload.sandbox),
capabilityLevel: stringOrNull(payload.capabilityLevel),
toolCalls: summarizeToolCalls(payload.toolCalls),
skills: summarizeSkills(payload.skills),
runnerTrace: summarizeRunnerTrace(payload.runnerTrace)
};
}
@@ -188,7 +275,14 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
backend,
traceId: observedTraceId,
hasReply: assistantReply.length > 0,
error
error,
runner: summarizeRunner(payload),
workspace: stringOrNull(payload.workspace),
sandbox: stringOrNull(payload.sandbox),
capabilityLevel: stringOrNull(payload.capabilityLevel),
toolCalls: summarizeToolCalls(payload.toolCalls),
skills: summarizeSkills(payload.skills),
runnerTrace: summarizeRunnerTrace(payload.runnerTrace)
};
}
@@ -282,6 +376,9 @@ export function inspectRealCompletionEvidence(payload) {
if (!trustedLiveProviders.has(provider)) {
return { ok: false, reason: `provider ${payload.provider} is not an accepted real provider` };
}
if (provider === "openai-responses" && payload?.capabilityLevel && payload.capabilityLevel !== "text-chat-only") {
return { ok: false, reason: "openai-responses cannot claim runner capabilityLevel" };
}
if (untrustedProviderPattern.test(`${provider} ${backend}`)) {
return { ok: false, reason: `provider/backend looks non-real: ${payload.provider}/${payload.backend}` };
}
@@ -324,6 +421,55 @@ function inspectSanitizedCompletionEvidence(summary) {
return { ok: true, reason: "real provider/backend/model/trace/reply evidence present" };
}
function summarizeRunner(payload) {
if (!payload?.runner || typeof payload.runner !== "object") return null;
return {
kind: stringOrNull(payload.runner.kind),
backend: stringOrNull(payload.runner.backend),
workspace: stringOrNull(payload.runner.workspace),
sandbox: stringOrNull(payload.runner.sandbox),
session: stringOrNull(payload.runner.session),
readOnly: payload.runner.readOnly === true,
capabilityLevel: stringOrNull(payload.runner.capabilityLevel)
};
}
function summarizeToolCalls(value) {
if (!Array.isArray(value)) return [];
return value.map((tool) => ({
name: stringOrNull(tool?.name),
type: stringOrNull(tool?.type),
status: stringOrNull(tool?.status),
exitCode: numericStatus(tool?.exitCode),
outputTruncated: tool?.outputTruncated === true
}));
}
function summarizeSkills(value) {
if (!value || typeof value !== "object") return null;
return {
status: stringOrNull(value.status),
count: typeof value.count === "number" ? value.count : Array.isArray(value.items) ? value.items.length : null,
totalCount: typeof value.totalCount === "number" ? value.totalCount : null,
blockers: Array.isArray(value.blockers)
? value.blockers.map((blocker) => ({
code: stringOrNull(blocker.code),
sourceIssue: stringOrNull(blocker.sourceIssue)
}))
: []
};
}
function summarizeRunnerTrace(value) {
if (!value || typeof value !== "object") return null;
return {
runnerKind: stringOrNull(value.runnerKind),
sandbox: stringOrNull(value.sandbox),
outputTruncated: value.outputTruncated === true,
valuesPrinted: value.valuesPrinted === true
};
}
function stringOrNull(value) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
+17 -1
View File
@@ -163,6 +163,13 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([
"provider",
"model",
"backend",
"runner",
"workspace",
"sandbox",
"capability",
"toolCalls",
"skills",
"runnerTrace",
"conversation",
"trace",
"message",
@@ -1797,11 +1804,20 @@ function hasCodeAgentCompletedEvidenceVisibility({ app }) {
/recordField\("provider",\s*message\.provider\)/u.test(messageEvidenceBody) &&
/recordField\("model",\s*message\.model\)/u.test(messageEvidenceBody) &&
/recordField\("backend",\s*message\.backend\)/u.test(messageEvidenceBody) &&
/recordField\("runner",\s*message\.runner\?\.kind\)/u.test(messageEvidenceBody) &&
/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\("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) &&
/recordField\("conversation",\s*message\.conversationId\)/u.test(messageEvidenceBody) &&
/recordField\("trace",\s*message\.traceId\)/u.test(messageEvidenceBody) &&
/recordField\("message",\s*message\.messageId\)/u.test(messageEvidenceBody) &&
/providerTraceSummary\(message\.providerTrace\)/u.test(messageEvidenceBody) &&
/isTrustedCodeAgentProvider\(value\?\.provider\)/u.test(realEvidenceBody) &&
/isCodexRunnerCapableEvidence\(value\)/u.test(realEvidenceBody) &&
/CODEX_RUNNER_CAPABLE_PROVIDERS/u.test(app) &&
/TRUSTED_CODE_AGENT_PROVIDERS/u.test(app) &&
/UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN/u.test(realEvidenceBody) &&
/value\?\.conversationId \|\| value\?\.sessionId/u.test(realEvidenceBody) &&
@@ -2866,7 +2882,7 @@ async function inspectJourneyUi(page) {
messageCount: document.querySelectorAll(".message-card").length,
completedMessageHasNonSensitiveMeta: Boolean(
[...document.querySelectorAll(".message-card.status-completed .message-meta")]
.some((element) => /openai-responses|gpt-5\.5|trc_/u.test(element.textContent ?? ""))
.some((element) => /openai-responses|codex-readonly-runner|gpt-5\.5|runner=|workspace=|toolCalls=|trc_/u.test(element.textContent ?? ""))
),
sourceMessageHasNonSensitiveMeta: Boolean(
[...document.querySelectorAll(".message-card.status-source .message-meta")]