diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index aae169bb..6752f1ee 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -10,6 +10,7 @@ const DEFAULT_CODE_AGENT_TIMEOUT_MS = 120000; const DEFAULT_CODEX_COMMAND = "codex"; const DEFAULT_MODEL = "gpt-5.2"; const DEFAULT_PROJECT_ID = "prj_hwlab-cloud-workbench"; +const CODE_AGENT_PROVIDER_SECRET_REF = "hwlab-code-agent-provider/openai-api-key"; const CODE_AGENT_SYSTEM_PROMPT = [ "你是 HWLAB 云工作台的 Code Agent。", "请用中文直接回答用户的工作台问题。", @@ -52,6 +53,14 @@ export async function handleCodeAgentChat(params = {}, options = {}) { now: options.now, callProvider: options.callProvider }); + const content = typeof providerResult.content === "string" ? providerResult.content.trim() : ""; + if (!content) { + throw providerUnavailable("Code Agent provider returned no assistant text", { + provider: providerResult.provider ?? base.provider, + model: providerResult.model ?? base.model, + backend: providerResult.backend ?? base.backend + }); + } const completedAt = nowIso(options.now); return { ...base, @@ -63,7 +72,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { reply: { messageId, role: "assistant", - content: providerResult.content, + content, createdAt: completedAt }, usage: providerResult.usage ?? null, @@ -71,12 +80,16 @@ export async function handleCodeAgentChat(params = {}, options = {}) { }; } catch (error) { const failedAt = nowIso(options.now); - return { + const payload = { ...base, status: "failed", updatedAt: failedAt, error: normalizeChatError(error) }; + if (error.code === "provider_unavailable") { + payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options); + } + return payload; } } @@ -107,11 +120,64 @@ export function validateCodeAgentChatSchema(payload) { if (payload.status === "failed" && typeof payload.error?.message !== "string") { throw new Error("failed code agent chat response must include error.message"); } - if (payload.status === "completed" && typeof payload.reply?.content !== "string") { - throw new Error("completed code agent chat response must include reply.content"); + if (payload.status === "completed" && (typeof payload.reply?.content !== "string" || !payload.reply.content.trim())) { + throw new Error("completed code agent chat response must include non-empty reply.content"); } } +export function describeCodeAgentAvailability(env = process.env, options = {}) { + const providerPlan = resolveProviderPlan(env, options); + const missingEnv = []; + + if (!env.OPENAI_API_KEY) { + missingEnv.push("OPENAI_API_KEY"); + } + + const blocked = missingEnv.length > 0; + return { + endpoint: "POST /v1/agent/chat", + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + mode: providerPlan.mode, + schema: [ + "conversationId", + "sessionId", + "messageId", + "status", + "createdAt", + "updatedAt", + "traceId", + "provider", + "model", + "backend", + "error.message", + "error.code", + "error.missingEnv", + "availability.status" + ], + status: blocked ? "blocked" : "available", + blocker: blocked ? "凭证缺口" : null, + reason: blocked ? "provider_unavailable" : null, + summary: blocked + ? `真实后端已接入,但当前 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 未注入,因此不能返回真实回复。` + : "真实后端已接入,发送会走真实 /v1/agent/chat;只有实际 completed 回复才标记 DEV-LIVE。", + missingEnv, + secretRefs: blocked + ? [ + { + env: "OPENAI_API_KEY", + secretName: "hwlab-code-agent-provider", + secretKey: "openai-api-key", + present: false, + redacted: true + } + ] + : [], + ready: !blocked + }; +} + function resolveProviderPlan(env, options = {}) { const provider = String(env.HWLAB_CODE_AGENT_PROVIDER || "auto").trim().toLowerCase(); const model = firstNonEmpty( diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 93c0d2e1..298802ed 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -12,7 +12,7 @@ import { createErrorEnvelope, handleJsonRpcRequest } from "./json-rpc.mjs"; -import { handleCodeAgentChat } from "./code-agent-chat.mjs"; +import { describeCodeAgentAvailability, handleCodeAgentChat } from "./code-agent-chat.mjs"; import { buildDbRuntimeReadiness } from "./db-contract.mjs"; import { createCloudRuntimeStore } from "../db/runtime-store.mjs"; @@ -41,6 +41,7 @@ export async function buildHealthPayload(options = {}) { const imageReference = process.env.HWLAB_IMAGE || "unknown"; const imageTag = process.env.HWLAB_IMAGE_TAG || commitId.slice(0, 7) || "unknown"; const db = await buildDbRuntimeReadiness(process.env, options.dbProbe); + const codeAgent = describeCodeAgentAvailability(options.env ?? process.env, options); return { serviceId, @@ -64,6 +65,7 @@ export async function buildHealthPayload(options = {}) { endpoint: process.env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT, observedAt: new Date().toISOString(), db, + codeAgent, runtime: options.runtimeStore?.summary?.() ?? createCloudRuntimeStore().summary() }; } @@ -147,23 +149,7 @@ async function handleRestAdapter(request, response, url, options) { adapter: "rest", status: "degraded", rpcBridge: "POST /v1/rpc/{method}", - codeAgent: { - endpoint: "POST /v1/agent/chat", - status: "available", - schema: [ - "conversationId", - "sessionId", - "messageId", - "status", - "createdAt", - "updatedAt", - "traceId", - "provider", - "model", - "backend", - "error.message" - ] - }, + codeAgent: describeCodeAgentAvailability(options.env ?? process.env, options), methods: SUPPORTED_RPC_METHODS, auditFields: AUDIT_FIELD_NAMES, db: await buildDbRuntimeReadiness(process.env, options.dbProbe), diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index a83ccef6..652572bc 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -10,7 +10,13 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { delete process.env.HWLAB_CLOUD_DB_URL; delete process.env.HWLAB_CLOUD_DB_SSL_MODE; - const server = createCloudApiServer(); + const server = createCloudApiServer({ + env: { + PATH: "", + HWLAB_CODE_AGENT_PROVIDER: "codex-cli", + HWLAB_CODE_AGENT_MODEL: "gpt-test" + } + }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); try { @@ -36,6 +42,14 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthPayload.db.safety.liveDbEvidence, false); assert.equal(healthPayload.runtime.durable, false); assert.equal(healthPayload.runtime.status, "degraded"); + assert.equal(healthPayload.codeAgent.status, "blocked"); + assert.equal(healthPayload.codeAgent.blocker, "凭证缺口"); + assert.equal(healthPayload.codeAgent.reason, "provider_unavailable"); + assert.deepEqual(healthPayload.codeAgent.missingEnv, ["OPENAI_API_KEY"]); + assert.equal(healthPayload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider"); + assert.equal(healthPayload.codeAgent.secretRefs[0].secretKey, "openai-api-key"); + assert.equal(healthPayload.codeAgent.secretRefs[0].redacted, true); + assert.equal(JSON.stringify(healthPayload.codeAgent).includes("sk-"), false); assert.deepEqual(healthPayload.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]); assert.equal(healthPayload.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db"); assert.equal(healthPayload.db.secretRefs[0].redacted, true); @@ -47,6 +61,8 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthLivePayload.status, "degraded"); assert.equal(healthLivePayload.commit.id.length > 0, true); assert.equal(healthLivePayload.db.ready, false); + assert.equal(healthLivePayload.codeAgent.status, "blocked"); + assert.equal(healthLivePayload.codeAgent.ready, false); const readiness = await fetch(`http://127.0.0.1:${port}/health/live`); assert.equal(readiness.status, 200); @@ -179,6 +195,45 @@ test("cloud api health does not treat DB env presence-only as live readiness", a } }); +test("cloud api /v1 describes Code Agent provider blocker without leaking secret values", async () => { + const server = createCloudApiServer({ + env: { + PATH: "", + HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_MODEL: "gpt-test" + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1`); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.codeAgent.endpoint, "POST /v1/agent/chat"); + assert.equal(payload.codeAgent.provider, "openai-responses"); + assert.equal(payload.codeAgent.model, "gpt-test"); + assert.equal(payload.codeAgent.backend, "hwlab-cloud-api/openai-responses"); + assert.equal(payload.codeAgent.mode, "openai"); + assert.equal(payload.codeAgent.status, "blocked"); + assert.equal(payload.codeAgent.blocker, "凭证缺口"); + assert.equal(payload.codeAgent.reason, "provider_unavailable"); + assert.deepEqual(payload.codeAgent.missingEnv, ["OPENAI_API_KEY"]); + assert.equal(payload.codeAgent.secretRefs[0].env, "OPENAI_API_KEY"); + assert.equal(payload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider"); + assert.equal(payload.codeAgent.secretRefs[0].secretKey, "openai-api-key"); + assert.equal(payload.codeAgent.secretRefs[0].redacted, true); + assert.equal(payload.codeAgent.ready, false); + assert.match(payload.codeAgent.summary, /真实后端已接入/u); + assert.match(payload.codeAgent.summary, /hwlab-code-agent-provider\/openai-api-key/u); + 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 returns structured completed Code Agent payload", async () => { const server = createCloudApiServer({ callCodeAgentProvider: async ({ providerPlan, message, traceId }) => ({ @@ -220,6 +275,7 @@ test("cloud api /v1/agent/chat returns structured completed Code Agent payload", assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false); assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false); assert.match(payload.reply.content, /HWLAB 工作台/); + assert.equal(Object.hasOwn(payload, "error"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -262,6 +318,56 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as assert.match(payload.error.message, /Codex CLI command is not available/); assert.deepEqual(payload.error.missingCommands, ["codex"]); assert.ok(payload.error.missingEnv.includes("OPENAI_API_KEY")); + assert.equal(payload.availability.status, "blocked"); + assert.equal(payload.availability.blocker, "凭证缺口"); + assert.equal(payload.availability.reason, "provider_unavailable"); + assert.equal(payload.availability.secretRefs[0].secretName, "hwlab-code-agent-provider"); + assert.equal(payload.availability.secretRefs[0].secretKey, "openai-api-key"); + assert.equal(payload.availability.secretRefs[0].redacted, true); + assert.match(payload.availability.summary, /真实后端已接入/u); + assert.match(payload.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u); + assert.equal(JSON.stringify(payload).includes("sk-"), false); + assert.equal(Object.hasOwn(payload, "reply"), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +test("cloud api /v1/agent/chat does not complete on empty provider text", async () => { + const server = createCloudApiServer({ + callCodeAgentProvider: async ({ providerPlan }) => ({ + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + content: " ", + usage: null, + providerTrace: { + source: "empty-test-provider" + } + }) + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ + conversationId: "cnv_empty-provider-text", + message: "你好" + }) + }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.conversationId, "cnv_empty-provider-text"); + assert.equal(payload.status, "failed"); + assert.equal(payload.error.code, "provider_unavailable"); + assert.match(payload.error.message, /no assistant text/); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { await new Promise((resolve, reject) => { diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index 16ab62fa..41450690 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -1,80 +1,334 @@ #!/usr/bin/env node import assert from "node:assert/strict"; +import http from "node:http"; +import https from "node:https"; import { handleCodeAgentChat, validateCodeAgentChatSchema } from "../internal/cloud/code-agent-chat.mjs"; +const defaultLiveUrl = "http://74.48.78.17:16666/"; +const defaultLiveMessage = "请用一句话回复 HWLAB Code Agent readiness。"; + +const args = parseArgs(process.argv.slice(2)); + +if (args.mode === "help") { + process.stdout.write(`${JSON.stringify(usage(), null, 2)}\n`); +} else if (args.mode === "live") { + const report = await runLiveReadinessSmoke(args); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.exitCode = report.status === "pass" ? 0 : 2; +} else { + await runLocalContractSmoke(); +} + function logOk(name) { process.stdout.write(`[code-agent-chat-smoke] ok ${name}\n`); } -const completed = await handleCodeAgentChat( - { - conversationId: "cnv_code-agent-chat-smoke", - traceId: "trc_code-agent-chat-smoke", - projectId: "prj_code-agent-chat-smoke", - message: "请用一句话说明当前 HWLAB 工作台可以做什么。" - }, - { - now: () => "2026-05-22T00:00:00.000Z", - callProvider: async ({ providerPlan }) => ({ - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend, - content: "HWLAB 工作台可以用 Code Agent 对话来整理任务、查看资源并保持硬件变更受控。", - usage: null, - providerTrace: { - source: "schema-smoke-provider" +async function runLocalContractSmoke() { + const completed = await handleCodeAgentChat( + { + conversationId: "cnv_code-agent-chat-smoke", + traceId: "trc_code-agent-chat-smoke", + projectId: "prj_code-agent-chat-smoke", + message: "请用一句话说明当前 HWLAB 工作台可以做什么。" + }, + { + now: () => "2026-05-22T00:00:00.000Z", + callProvider: async ({ providerPlan }) => ({ + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + content: "HWLAB 工作台可以用 Code Agent 对话来整理任务、查看资源并保持硬件变更受控。", + usage: null, + providerTrace: { + source: "schema-smoke-provider" + } + }) + } + ); + + validateCodeAgentChatSchema(completed); + assert.equal(completed.conversationId, "cnv_code-agent-chat-smoke"); + assert.equal(completed.sessionId, "cnv_code-agent-chat-smoke"); + assert.match(completed.messageId, /^msg_/); + assert.equal(completed.status, "completed"); + assert.equal(completed.createdAt, "2026-05-22T00:00:00.000Z"); + assert.equal(completed.updatedAt, "2026-05-22T00:00:00.000Z"); + assert.equal(completed.traceId, "trc_code-agent-chat-smoke"); + assert.equal(completed.error, undefined); + assert.match(completed.reply.content, /Code Agent/); + logOk("completed schema"); + + const sourceReadiness = classifyCodeAgentChatReadiness(completed, { realDevLive: false }); + assert.equal(sourceReadiness.status, "blocked"); + assert.equal(sourceReadiness.level, "SOURCE"); + assert.equal(sourceReadiness.devLiveReplyPass, false); + logOk("local stub completion is not #143 DEV-LIVE pass"); + + const failed = await handleCodeAgentChat( + { + traceId: "trc_code-agent-chat-smoke-failed", + message: "你好" + }, + { + now: () => "2026-05-22T00:01:00.000Z", + env: { + PATH: "", + HWLAB_CODE_AGENT_PROVIDER: "codex-cli", + HWLAB_CODE_AGENT_MODEL: "gpt-test" } - }) + } + ); + + validateCodeAgentChatSchema(failed); + assert.match(failed.conversationId, /^cnv_/); + assert.match(failed.sessionId, /^cnv_/); + assert.match(failed.messageId, /^msg_/); + assert.equal(failed.status, "failed"); + assert.equal(failed.createdAt, "2026-05-22T00:01:00.000Z"); + assert.equal(failed.updatedAt, "2026-05-22T00:01:00.000Z"); + assert.equal(failed.traceId, "trc_code-agent-chat-smoke-failed"); + assert.equal(failed.provider, "codex-cli"); + assert.equal(failed.model, "gpt-test"); + assert.equal(failed.backend, "hwlab-cloud-api/codex-cli"); + assert.equal(failed.error.code, "provider_unavailable"); + 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.equal(failed.availability.blocker, "凭证缺口"); + assert.equal(failed.availability.reason, "provider_unavailable"); + assert.match(failed.availability.summary, /真实后端已接入/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); + logOk("failed provider-gap schema"); + + const credentialReadiness = classifyCodeAgentChatReadiness(failed, { realDevLive: true }); + assert.equal(credentialReadiness.status, "blocked"); + assert.equal(credentialReadiness.level, "BLOCKED/credential"); + assert.equal(credentialReadiness.blocker, "credential"); + assert.equal(credentialReadiness.devLiveReplyPass, false); + logOk("provider credential blocker readiness"); + + process.stdout.write("[code-agent-chat-smoke] passed\n"); +} + +async function runLiveReadinessSmoke(args) { + const endpoint = agentChatEndpoint(args.url); + const traceId = protocolId("trc_code-agent-chat-live"); + const conversationId = protocolId("cnv_code-agent-chat-live"); + let httpStatus = null; + let payload = null; + let transportError = null; + + try { + const response = await postJson(endpoint, { + headers: { + "x-trace-id": traceId + }, + body: { + conversationId, + traceId, + projectId: "prj_hwlab-cloud-workbench", + message: args.message + } + }); + httpStatus = response.status; + payload = response.json; + } catch (error) { + transportError = error instanceof Error ? error.message : String(error); } -); -validateCodeAgentChatSchema(completed); -assert.equal(completed.conversationId, "cnv_code-agent-chat-smoke"); -assert.equal(completed.sessionId, "cnv_code-agent-chat-smoke"); -assert.match(completed.messageId, /^msg_/); -assert.equal(completed.status, "completed"); -assert.equal(completed.createdAt, "2026-05-22T00:00:00.000Z"); -assert.equal(completed.updatedAt, "2026-05-22T00:00:00.000Z"); -assert.equal(completed.traceId, "trc_code-agent-chat-smoke"); -assert.equal(completed.error, undefined); -assert.match(completed.reply.content, /Code Agent/); -logOk("completed schema"); + const readiness = payload + ? classifyCodeAgentChatReadiness(payload, { realDevLive: true, httpStatus }) + : { + status: "blocked", + level: "BLOCKED", + blocker: "transport", + devLiveReplyPass: false, + reason: "real DEV /v1/agent/chat did not return a JSON Code Agent payload" + }; -const failed = await handleCodeAgentChat( - { - traceId: "trc_code-agent-chat-smoke-failed", - message: "你好" - }, - { - now: () => "2026-05-22T00:01:00.000Z", - env: { - PATH: "", - HWLAB_CODE_AGENT_PROVIDER: "codex-cli", - HWLAB_CODE_AGENT_MODEL: "gpt-test" + if (payload) { + try { + validateCodeAgentChatSchema(payload); + } catch (error) { + readiness.status = "blocked"; + readiness.level = "BLOCKED"; + readiness.blocker = "schema"; + readiness.devLiveReplyPass = false; + readiness.reason = error instanceof Error ? error.message : String(error); } } -); -validateCodeAgentChatSchema(failed); -assert.match(failed.conversationId, /^cnv_/); -assert.match(failed.sessionId, /^cnv_/); -assert.match(failed.messageId, /^msg_/); -assert.equal(failed.status, "failed"); -assert.equal(failed.createdAt, "2026-05-22T00:01:00.000Z"); -assert.equal(failed.updatedAt, "2026-05-22T00:01:00.000Z"); -assert.equal(failed.traceId, "trc_code-agent-chat-smoke-failed"); -assert.equal(failed.provider, "codex-cli"); -assert.equal(failed.model, "gpt-test"); -assert.equal(failed.backend, "hwlab-cloud-api/codex-cli"); -assert.equal(failed.error.code, "provider_unavailable"); -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(Object.hasOwn(failed, "reply"), false); -logOk("failed provider-gap schema"); + return { + status: readiness.status, + mode: "live-readiness", + url: endpoint.href, + httpStatus, + readiness, + response: summarizePayload(payload), + ...(transportError ? { transportError } : {}), + safety: { + prodTouched: false, + servicesRestarted: false, + readsSecretValues: false, + printsAssistantReply: false, + statement: "This smoke posts only to the configured DEV Code Agent chat endpoint and prints reply presence, not reply content or secret values." + } + }; +} -process.stdout.write("[code-agent-chat-smoke] passed\n"); +function classifyCodeAgentChatReadiness(payload, { realDevLive = false, httpStatus = null } = {}) { + const assistantReply = typeof payload?.reply?.content === "string" ? payload.reply.content.trim() : ""; + if (payload?.status === "completed" && assistantReply) { + if (realDevLive) { + return { + status: "pass", + level: "#143 DEV-LIVE reply pass", + blocker: null, + devLiveReplyPass: true, + reason: "real DEV /v1/agent/chat completed with a non-empty assistant reply" + }; + } + return { + status: "blocked", + level: "SOURCE", + blocker: "not-dev-live", + devLiveReplyPass: false, + reason: "mock, fixture, local stub, or source-only completion cannot be counted as #143 DEV-LIVE reply pass" + }; + } + + const missingEnv = Array.isArray(payload?.error?.missingEnv) ? payload.error.missingEnv : []; + if (payload?.status === "failed" && payload.error?.code === "provider_unavailable" && missingEnv.includes("OPENAI_API_KEY")) { + return { + status: "blocked", + level: "BLOCKED/credential", + blocker: "credential", + devLiveReplyPass: false, + reason: "provider_unavailable with missing OPENAI_API_KEY means the provider credential is absent" + }; + } + + return { + status: "blocked", + level: "BLOCKED", + blocker: "runtime", + devLiveReplyPass: false, + reason: `Code Agent chat did not produce a completed non-empty assistant reply${httpStatus ? ` (HTTP ${httpStatus})` : ""}` + }; +} + +function summarizePayload(payload) { + const assistantReply = typeof payload?.reply?.content === "string" ? payload.reply.content.trim() : ""; + const error = payload?.error + ? { + code: payload.error.code, + missingEnv: payload.error.missingEnv, + missingCommands: payload.error.missingCommands, + providerStatus: payload.error.providerStatus + } + : null; + return { + status: payload?.status ?? null, + provider: payload?.provider ?? null, + model: payload?.model ?? null, + backend: payload?.backend ?? null, + assistantReplyNonEmpty: assistantReply.length > 0, + assistantReplyLength: assistantReply.length, + error + }; +} + +function parseArgs(argv) { + const parsed = { mode: "local", url: defaultLiveUrl, message: defaultLiveMessage }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--live") { + parsed.mode = "live"; + } else if (arg === "--url") { + index += 1; + if (!argv[index]) throw new Error("--url requires a value"); + parsed.url = argv[index]; + } else if (arg === "--message") { + index += 1; + if (!argv[index]) throw new Error("--message requires a value"); + parsed.message = argv[index]; + } else if (arg === "--help") { + return { mode: "help" }; + } else { + throw new Error(`unknown argument ${arg}`); + } + } + return parsed; +} + +function usage() { + return { + status: "usage", + command: "node scripts/code-agent-chat-smoke.mjs [--live --url http://74.48.78.17:16666/]", + notes: [ + "Default mode is local schema/readiness contract only and cannot pass #143 DEV-LIVE.", + "--live posts a minimal chat prompt to real DEV and passes only on completed plus non-empty assistant reply.", + "provider_unavailable with missing OPENAI_API_KEY is reported as BLOCKED/credential." + ] + }; +} + +function agentChatEndpoint(value) { + const url = new URL(value); + if (url.pathname.endsWith("/v1/agent/chat")) return url; + return new URL("/v1/agent/chat", url); +} + +function postJson(url, { headers = {}, body, timeoutMs = 10000 }) { + const client = url.protocol === "https:" ? https : http; + const payload = JSON.stringify(body); + return new Promise((resolve, reject) => { + const request = client.request( + url, + { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + ...headers + }, + insecureHTTPParser: true, + timeout: timeoutMs + }, + (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + resolve({ + status: response.statusCode ?? null, + body: text, + json: parseJsonOrNull(text) + }); + }); + } + ); + request.on("timeout", () => request.destroy(new Error(`request timed out after ${timeoutMs}ms`))); + request.on("error", reject); + request.end(payload); + }); +} + +function parseJsonOrNull(value) { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function protocolId(prefix) { + return `${prefix}_${Date.now().toString(36)}`; +} diff --git a/scripts/m1-contract-smoke.mjs b/scripts/m1-contract-smoke.mjs index 346e37b9..18540c77 100644 --- a/scripts/m1-contract-smoke.mjs +++ b/scripts/m1-contract-smoke.mjs @@ -7,6 +7,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs"; +import { validateCodeAgentChatSchema } from "../internal/cloud/code-agent-chat.mjs"; import { runCli } from "../tools/hwlab-cli/lib/cli.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -141,7 +142,11 @@ async function smokeCloudApi() { HWLAB_CLOUD_API_HOST: "127.0.0.1", HWLAB_CLOUD_API_PORT: String(port), HWLAB_CLOUD_DB_URL: "", - HWLAB_CLOUD_DB_SSL_MODE: "" + HWLAB_CLOUD_DB_SSL_MODE: "", + HWLAB_CODE_AGENT_PROVIDER: "codex-cli", + HWLAB_CODE_AGENT_MODEL: "gpt-m1-local", + OPENAI_API_KEY: "", + PATH: "" }); const baseUrl = `http://127.0.0.1:${port}`; @@ -162,6 +167,14 @@ async function smokeCloudApi() { assert.deepEqual(health.body.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]); assert.equal(health.body.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db"); assert.equal(health.body.db.secretRefs[0].redacted, true); + assert.equal(health.body.codeAgent.status, "blocked"); + assert.equal(health.body.codeAgent.blocker, "凭证缺口"); + assert.equal(health.body.codeAgent.reason, "provider_unavailable"); + assert.ok(health.body.codeAgent.missingEnv.includes("OPENAI_API_KEY")); + assert.equal(health.body.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider"); + assert.equal(health.body.codeAgent.secretRefs[0].secretKey, "openai-api-key"); + assert.equal(health.body.codeAgent.secretRefs[0].redacted, true); + assert.equal(JSON.stringify(health.body.codeAgent).includes("sk-"), false); const live = await requestJson(`${baseUrl}/live`); assert.equal(live.response.status, 200); @@ -176,6 +189,53 @@ async function smokeCloudApi() { assert.ok(adapter.body.methods.includes("hardware.operation.request")); assert.ok(adapter.body.methods.includes("audit.event.query")); assert.ok(adapter.body.methods.includes("evidence.record.query")); + assert.equal(adapter.body.codeAgent.endpoint, "POST /v1/agent/chat"); + assert.equal(adapter.body.codeAgent.status, "blocked"); + assert.equal(adapter.body.codeAgent.blocker, "凭证缺口"); + assert.equal(adapter.body.codeAgent.provider, "codex-cli"); + assert.equal(adapter.body.codeAgent.model, "gpt-m1-local"); + assert.equal(adapter.body.codeAgent.backend, "hwlab-cloud-api/codex-cli"); + assert.equal(adapter.body.codeAgent.ready, false); + assert.ok(adapter.body.codeAgent.missingEnv.includes("OPENAI_API_KEY")); + assert.equal(adapter.body.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider"); + assert.equal(adapter.body.codeAgent.secretRefs[0].secretKey, "openai-api-key"); + assert.equal(adapter.body.codeAgent.secretRefs[0].redacted, true); + assert.match(adapter.body.codeAgent.summary, /hwlab-code-agent-provider\/openai-api-key/u); + assert.equal(JSON.stringify(adapter.body.codeAgent).includes("sk-"), false); + + const agentChat = await requestJson(`${baseUrl}/v1/agent/chat`, { + method: "POST", + body: JSON.stringify({ + conversationId: "cnv_m1_local_chat", + traceId: "trc_m1_local_chat", + projectId: "prj_m1_local_contract", + message: "请用一句话说明本地 SOURCE/LOCAL 合同。" + }) + }); + assert.equal(agentChat.response.status, 200); + validateCodeAgentChatSchema(agentChat.body); + assert.equal(agentChat.body.conversationId, "cnv_m1_local_chat"); + assert.equal(agentChat.body.sessionId, "cnv_m1_local_chat"); + assert.match(agentChat.body.messageId, /^msg_/); + assert.equal(agentChat.body.status, "failed"); + assert.equal(agentChat.body.traceId, "trc_m1_local_chat"); + assert.equal(agentChat.body.projectId, "prj_m1_local_contract"); + assert.equal(agentChat.body.provider, "codex-cli"); + assert.equal(agentChat.body.model, "gpt-m1-local"); + assert.equal(agentChat.body.backend, "hwlab-cloud-api/codex-cli"); + assert.equal(agentChat.body.error.code, "provider_unavailable"); + assert.match(agentChat.body.error.message, /Codex CLI command is not available/); + assert.deepEqual(agentChat.body.error.missingCommands, ["codex"]); + assert.ok(agentChat.body.error.missingEnv.includes("OPENAI_API_KEY")); + assert.equal(agentChat.body.availability.status, "blocked"); + assert.equal(agentChat.body.availability.blocker, "凭证缺口"); + assert.equal(agentChat.body.availability.reason, "provider_unavailable"); + assert.equal(agentChat.body.availability.secretRefs[0].secretName, "hwlab-code-agent-provider"); + assert.equal(agentChat.body.availability.secretRefs[0].secretKey, "openai-api-key"); + assert.equal(agentChat.body.availability.secretRefs[0].redacted, true); + assert.match(agentChat.body.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u); + assert.equal(JSON.stringify(agentChat.body).includes("sk-"), false); + assert.equal(Object.hasOwn(agentChat.body, "reply"), false); const rpcHealth = await requestJson(`${baseUrl}/rpc`, { method: "POST", diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index f3460439..8a330b5b 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -207,6 +207,11 @@ function runStaticSmoke() { evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"] }); + addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider credential blockers and only real completed replies can become DEV-LIVE.", { + blocker: "observability_blocker", + evidence: ["BLOCKED 凭证缺口", "provider_unavailable", "OPENAI_API_KEY", "hwlab-code-agent-provider/openai-api-key", "completed -> dev-live guard"] + }); + addCheck(checks, blockers, "no-hardware-write-api", noForbiddenWriteSurface(source), "Workbench source does not expose hardware write APIs or live mutation commands.", { blocker: "safety_blocker", evidence: forbiddenWritePatterns.map(String) @@ -475,6 +480,29 @@ function hasCodeAgentChatContract({ html, app }) { ); } +function hasCodeAgentReadinessVisibility({ html, app }) { + const source = `${html}\n${app}`; + return ( + /codeAgentAvailability/u.test(app) && + /codeAgentAvailabilityFrom/u.test(app) && + /latestCompletedAgentMessage/u.test(app) && + /BLOCKED 凭证缺口/u.test(app) && + /provider_unavailable/u.test(app) && + /OPENAI_API_KEY/u.test(app) && + /hwlab-code-agent-provider\/openai-api-key/u.test(app) && + /只有真实 completed 回复才标 DEV-LIVE/u.test(app) && + /不能因为只有 conversationId 标为开发实况/u.test(app) && + /Boolean\(message\.provider\)/u.test(app) && + /Boolean\(message\.model\)/u.test(app) && + /Boolean\(message\.backend\)/u.test(app) && + /status === "completed" \? "dev-live"/u.test(app) && + !/status === "failed" \? "dev-live"/u.test(app) && + !/tone:\s*state\.conversationId\s*\?\s*"dev-live"/u.test(app) && + !/provider_unavailable[\s\S]{0,120}tone-[\w-]*dev-live/iu.test(source) && + !/sk-[A-Za-z0-9._-]{8,}/u.test(source) + ); +} + function noForbiddenWriteSurface(source) { return forbiddenWritePatterns.every((pattern) => !pattern.test(source)); } diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index b7c98e57..6f703534 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -13,6 +13,7 @@ const rpcReadMethods = Object.freeze([ const STATUS_LABELS = Object.freeze({ active: "活动", available: "可用", + blocked: "阻塞 BLOCKED", completed: "完成", connected: "已连接", degraded: "降级", @@ -33,8 +34,7 @@ const STATUS_LABELS = Object.freeze({ requires: "依赖", source: "来源 SOURCE", "dry-run": "演练 DRY-RUN", - "dev-live": "开发实况 DEV-LIVE", - blocked: "阻塞 BLOCKED" + "dev-live": "开发实况 DEV-LIVE" }); const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view)); @@ -77,6 +77,7 @@ const el = { const state = { conversationId: null, + codeAgentAvailability: null, chatMessages: [], chatPending: false }; @@ -247,8 +248,12 @@ function initCommandBar() { model: result.model, backend: result.backend, updatedAt: result.updatedAt, - error: result.error + error: result.error, + availability: result.availability }; + if (result.availability) { + state.codeAgentAvailability = result.availability; + } if (result.status !== "completed") { el.commandInput.value = value; } @@ -441,14 +446,18 @@ function renderLiveSurface(live) { el.liveStatus.textContent = reachable ? "只读连接可用" : "离线查看"; el.liveStatus.className = `tone-${toneClass(reachable ? "dev-live" : "source")}`; + state.codeAgentAvailability = codeAgentAvailabilityFrom(live); el.liveDetail.textContent = reachable - ? "同源只读接口可响应。技术复核详情已放在二级页面。" + ? codeAgentAvailabilitySummary() : "同源 API 暂不可用,当前仍可使用本地草稿和已加载资源。"; + renderAgentChatStatus(deriveAgentChatStatus()); renderHardwareStatus(runtimeSummary); renderRecords(live); renderDiagnostics(live); renderMethodList(methods.length > 0 ? methods.filter((method) => rpcReadMethods.includes(method)) : rpcReadMethods, reachable ? "live" : "degraded"); + renderConversation(); + renderDrafts(); } function renderResourceTree() { @@ -540,12 +549,7 @@ function renderConversation() { text: "这里是用户工作台:可以整理任务、查看资源、核对接线和可信记录。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。", status: "source" }, - { - role: "agent", - title: "Code Agent", - text: "请在底部输入中文消息并点击发送,我会通过 cloud-api 调用真实 Code Agent / Codex 能力返回回复。", - status: "source" - } + codeAgentStatusMessage(state.codeAgentAvailability) ]; replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard)); } @@ -648,10 +652,8 @@ function controlRows() { }, { title: "Code Agent 对话", - detail: state.conversationId - ? `当前会话 ${state.conversationId};回复来自 cloud-api 受控 Code Agent 接口。` - : "输入区会调用 cloud-api 受控 Code Agent 接口,不会直接调用硬件写入方法。", - tone: state.conversationId ? "dev-live" : "source" + detail: codeAgentControlSummary(state.codeAgentAvailability), + tone: currentConversationTone() }, { title: "只读探测", @@ -672,8 +674,8 @@ function renderDrafts() { ? [ { title: "对话状态", - detail: "暂无消息。输入区会发送到 Code Agent 后端,失败时会保留输入内容。", - tone: "source" + detail: codeAgentPromptText(state.codeAgentAvailability), + tone: state.codeAgentAvailability?.status === "blocked" ? "blocked" : "source" } ] : []), @@ -860,10 +862,11 @@ function renderAgentChatStatus(status, result = null) { idle: "等待输入", running: "处理中", completed: "已回复", - failed: "发送失败" + failed: "发送失败", + blocked: "BLOCKED 凭证缺口" }; el.agentChatStatus.textContent = labels[status] ?? statusLabel(status); - el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : status === "failed" ? "blocked" : "source")}`; + el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : status === "failed" || status === "blocked" ? "blocked" : "source")}`; el.commandInput.disabled = status === "running"; el.commandSend.disabled = status === "running"; el.commandSend.textContent = status === "running" ? "发送中" : "发送"; @@ -879,6 +882,13 @@ function sourceTitle(result) { } function failureMessage(result) { + if (result?.error?.code === "provider_unavailable" || result?.availability?.status === "blocked") { + const availability = result.availability ?? codeAgentAvailabilityFromResult(result); + const missing = Array.isArray(result.error?.missingEnv) && result.error.missingEnv.length > 0 + ? ` 当前缺口:${result.error.missingEnv.join("、")}。` + : ""; + return `${availability.summary}${missing}`; + } const missing = [ ...(result.error?.missingCommands ?? []), ...(result.error?.missingEnv ?? []) @@ -985,6 +995,116 @@ function messageFromPayload(payload) { return payload.error?.message || payload.error?.code || payload.message || JSON.stringify(payload).slice(0, 160); } +function codeAgentAvailabilityFrom(live) { + return live?.restIndex?.data?.codeAgent ?? live?.healthLive?.data?.codeAgent ?? null; +} + +function codeAgentAvailabilityFromResult(result) { + return { + status: "blocked", + blocker: "凭证缺口", + reason: result?.error?.code ?? "provider_unavailable", + summary: "真实后端已接入,但当前 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入,因此不能返回真实回复。", + missingEnv: result?.error?.missingEnv ?? [], + secretRefs: [ + { + env: "OPENAI_API_KEY", + secretName: "hwlab-code-agent-provider", + secretKey: "openai-api-key", + redacted: true + } + ] + }; +} + +function codeAgentStatusMessage(availability) { + if (availability?.status === "blocked") { + return { + role: "system", + title: "Code Agent 状态:BLOCKED 凭证缺口", + text: codeAgentBlockedSummary(availability), + status: "blocked" + }; + } + if (availability?.status === "available") { + return { + role: "system", + title: "Code Agent 状态:真实通道已接入", + text: "真实 /v1/agent/chat 后端已接入;发送后只有实际 completed 回复才会标记为 DEV-LIVE。", + status: "source" + }; + } + return { + role: "system", + title: "Code Agent 状态:等待只读探测", + text: "正在读取 same-origin /v1 与 /health/live;首屏不会宣称 Code Agent 可用,也不会把失败或静态内容标成 DEV-LIVE。", + status: "source" + }; +} + +function codeAgentPromptText(availability) { + if (availability?.status === "blocked") { + return "可以继续输入并保留草稿;发送会走真实 /v1/agent/chat,但当前 DEV provider 凭证缺口会返回结构化失败,不会冒充真实 Codex 回复。"; + } + return "请在底部输入中文消息并点击发送;完成态只来自真实 /v1/agent/chat completed 回复。"; +} + +function codeAgentControlSummary(availability) { + if (latestCompletedAgentMessage()) { + return state.conversationId + ? `当前会话 ${state.conversationId};最近一次真实 completed 回复来自 cloud-api 受控 Code Agent 接口。` + : "最近一次真实 completed 回复来自 cloud-api 受控 Code Agent 接口。"; + } + if (availability?.status === "blocked") { + return codeAgentBlockedSummary(availability); + } + return "输入区会调用 cloud-api 受控 Code Agent 接口;只有真实 completed 回复才标 DEV-LIVE,不能因为只有 conversationId 标为开发实况。"; +} + +function codeAgentBlockedSummary(availability) { + return availability?.summary ?? "真实后端已接入,但当前 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入,因此不能返回真实回复。"; +} + +function codeAgentAvailabilitySummary() { + if (state.codeAgentAvailability?.status === "blocked") { + return "同源 API 可响应;Code Agent 为 BLOCKED/凭证缺口,真实后端已接入但 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入。"; + } + if (state.codeAgentAvailability?.status === "available") { + return "同源 API 可响应;Code Agent 真实通道已接入,只有 completed 回复才标 DEV-LIVE。"; + } + return "同源只读接口可响应。技术复核详情已放在二级页面。"; +} + +function latestCompletedAgentMessage() { + return [...state.chatMessages].reverse().find((message) => + message.role === "agent" && + message.status === "completed" && + !message.error && + Boolean(message.provider) && + Boolean(message.model) && + Boolean(message.backend) + ) ?? null; +} + +function latestChatResult() { + return [...state.chatMessages].reverse().find((message) => message.role === "agent") ?? null; +} + +function deriveAgentChatStatus() { + if (state.chatPending) return "running"; + const latest = latestChatResult(); + if (latest?.status === "completed") return "completed"; + if (latest?.status === "failed") return latest.error?.code === "provider_unavailable" ? "blocked" : "failed"; + if (state.codeAgentAvailability?.status === "blocked") return "blocked"; + return "idle"; +} + +function currentConversationTone() { + if (latestCompletedAgentMessage()) return "dev-live"; + if (state.codeAgentAvailability?.status === "blocked" || latestChatResult()?.status === "failed") return "blocked"; + return "source"; +} + function statusLabel(value) { const key = String(value ?? "unknown").trim(); const normalized = key.toLowerCase(); diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 6739512f..95fb73d9 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -183,6 +183,23 @@ assert.doesNotMatch(html, /主流程[\s\S]*添加草稿/u); assert.match(app, /sendAgentMessage/); assert.match(app, /fetchJson\("\/v1\/agent\/chat"/); assert.match(app, /Code Agent 调用失败/); +assert.match(app, /codeAgentAvailability/); +assert.match(app, /codeAgentAvailabilityFrom/); +assert.match(app, /latestCompletedAgentMessage/); +assert.match(app, /BLOCKED 凭证缺口/); +assert.match(app, /provider_unavailable/); +assert.match(app, /OPENAI_API_KEY/); +assert.match(app, /hwlab-code-agent-provider\/openai-api-key/); +assert.match(app, /只有真实 completed 回复才标 DEV-LIVE/); +assert.match(app, /不能因为只有 conversationId 标为开发实况/); +assert.match(app, /Boolean\(message\.provider\)/); +assert.match(app, /Boolean\(message\.model\)/); +assert.match(app, /Boolean\(message\.backend\)/); +assert.match(app, /status === "completed" \? "dev-live"/); +assert.doesNotMatch(app, /status === "failed" \? "dev-live"/); +assert.doesNotMatch(`${html}\n${app}`, /provider_unavailable[\s\S]{0,120}tone-[\w-]*dev-live/iu); +assert.doesNotMatch(app, /tone:\s*state\.conversationId\s*\?\s*"dev-live"/); +assert.doesNotMatch(`${html}\n${app}`, /sk-[A-Za-z0-9._-]{8,}/u); assert.match(app, /state\.conversationId/); assert.match(app, /conversationId/); assert.match(app, /messageId/);