feat: add read-only Code Agent runner
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user