diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index c82ba4a1..bbb89b53 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -796,6 +796,7 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses const threadId = safeOpaqueId(params.threadId); const hwlabProjectId = firstNonEmpty(params.projectId); const toolAliases = filteredResourceToolAliases(toolCapabilities); + const workspaceFiles = normalizeResourceWorkspaceFiles(params.workspaceFiles ?? params.resourceWorkspaceFiles); const resourceBundleRef = { kind: "git", repoUrl: resolveAgentRunRepoUrl(env), @@ -804,7 +805,8 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses lfs: false, toolAliases, promptRefs: HWLAB_RESOURCE_PROMPT_REFS, - skillRefs: HWLAB_RESOURCE_SKILL_REFS + skillRefs: HWLAB_RESOURCE_SKILL_REFS, + ...(workspaceFiles.length > 0 ? { workspaceFiles } : {}) }; return { tenantId: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_TENANT_ID, DEFAULT_TENANT_ID), @@ -858,6 +860,21 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses }; } +function normalizeResourceWorkspaceFiles(value) { + if (value == null || value === false || value === "") return []; + if (!Array.isArray(value)) throw adapterError("invalid_workspace_files", "workspaceFiles must be an array when provided"); + return value.map((item, index) => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw adapterError("invalid_workspace_file", `workspaceFiles[${index}] must be an object`); + const pathValue = text(item.path); + const content = typeof item.content === "string" ? item.content : ""; + const encoding = text(item.encoding) || "utf8"; + if (!pathValue || pathValue === "." || pathValue.startsWith("/") || pathValue.includes("..") || pathValue.includes("\\")) throw adapterError("invalid_workspace_file_path", `workspaceFiles[${index}].path must be a safe relative POSIX path`); + if (typeof item.content !== "string") throw adapterError("invalid_workspace_file_content", `workspaceFiles[${index}].content must be a utf8 string`); + if (encoding !== "utf8") throw adapterError("invalid_workspace_file_encoding", `workspaceFiles[${index}].encoding must be utf8`); + return { path: pathValue, content, encoding }; + }); +} + function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }) { const prompt = String(params.message ?? params.prompt ?? "").trim(); const threadId = safeOpaqueId(params.threadId); diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 406a1238..15fe2b40 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -307,6 +307,9 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte { name: "hwpod-ctl", path: "skills/hwpod-ctl/SKILL.md", required: true, aggregateAs: "hwpod-ctl" }, { name: "hwlab-agent-runtime", path: "skills/hwlab-agent-runtime/SKILL.md", required: true, aggregateAs: "hwlab-agent-runtime" } ]); + assert.deepEqual(body.resourceBundleRef.workspaceFiles, [ + { path: ".hwlab/hwpod-spec.yaml", content: "kind: Hwpod\nspec:\n workspace:\n path: F:\\Work\\CASE\n", encoding: "utf8" } + ]); const toolCredentials = body.executionPolicy.secretScope.toolCredentials; assert.equal(toolCredentials.some((item) => item.tool === "github" && item.projection.envName === "GH_TOKEN" && item.secretRef.name === "agentrun-v01-tool-github-pr"), true); assert.equal(toolCredentials.some((item) => item.tool === "unidesk-ssh" && item.projection.envName === "UNIDESK_SSH_CLIENT_TOKEN" && item.secretRef.name === "agentrun-v01-tool-unidesk-ssh"), true); @@ -527,7 +530,8 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte ownerRole: "user", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", retryOf: "trc_previous-agentrun-web", - message: "AgentRun adapter smoke" + message: "AgentRun adapter smoke", + workspaceFiles: [{ path: ".hwlab/hwpod-spec.yaml", content: "kind: Hwpod\nspec:\n workspace:\n path: F:\\Work\\CASE\n", encoding: "utf8" }] }) }); assert.equal(submit.status, 202); diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index ed839a37..5bf0860d 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -406,13 +406,22 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit assert.match(prompt, /Run-local HWPOD spec/u); assert.match(prompt, /verificationMode: compile-only build check/u); assert.doesNotMatch(prompt, /compile-only 阶段不要修改 subject 源码/u); + assert.doesNotMatch(prompt, /Write exactly this YAML/u); + assert.doesNotMatch(prompt, /```yaml/u); + assert.doesNotMatch(prompt, /apiVersion: hwlab\.pikastech\.com/u); + assert.doesNotMatch(prompt, /path: "F:\\\\Work\\\\HWLAB-CASE-F103/u); + assert.match(prompt, /CaseRun has already placed the run-local spec/u); assert.match(prompt, /hwpod-ctl spec validate --spec \.hwlab\/hwpod-spec\.yaml/u); - assert.match(prompt, /hwpod build --spec \.hwlab\/hwpod-spec\.yaml/u); - assert.match(prompt, /path: "F:\\\\Work\\\\HWLAB-CASE-F103\\\\\.worktree\\\\caserun-run-agent-flow"/u); + assert.match(prompt, /standard hwpod\/hwpod-ctl commands/u); const diff = await readFile(path.join(cwd, ".state", "hwlab-cli", "caserun", "run-agent-flow", "agent-diff.patch"), "utf8"); assert.match(diff, /printf\("hello"\)/u); assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/sessions"), true); assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat"), true); + const agentChatRequest = requests.find((item) => item.url === "http://web.test/v1/agent/chat"); + assert.equal(agentChatRequest.body.workspaceFiles[0].path, ".hwlab/hwpod-spec.yaml"); + assert.equal(agentChatRequest.body.workspaceFiles[0].encoding, "utf8"); + assert.match(agentChatRequest.body.workspaceFiles[0].content, /HWLAB-CASE-F103.*caserun-run-agent-flow/u); + assert.doesNotMatch(agentChatRequest.body.message, /path: "F:\\Work\\HWLAB-CASE-F103\\\.worktree\\caserun-run-agent-flow"/u); assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat/result/" + result.payload.agent.traceId), true); assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat/trace/" + result.payload.agent.traceId), true); } finally { diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index e87f267e..93a947c9 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -75,6 +75,29 @@ test("hwpod-node executes debug ops only through explicit node-side command bind } }); +test("hwpod-node executes debug commandRuns without shell splitting", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-debug-runs-")); + try { + const writer = path.join(root, "writer.js"); + await writeFile(writer, "const { writeFileSync } = require('node:fs'); writeFileSync(process.argv[2], process.argv.slice(3).join('|'));\n", "utf8"); + const result = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_debug_runs", + hwpodId: "hwpod-local", + nodeId: "pc-host-1", + ops: [{ opId: "op_download", op: "debug.download", args: { workspacePath: root, commandRuns: [{ command: process.execPath, argv: [writer, path.join(root, "args.txt"), "CherryUSB MicroLink CMSIS-DAP"] }] } }] + }, { now: () => "2026-06-05T00:00:00.000Z" }); + + assert.equal(result.ok, true); + assert.equal(result.results[0].ok, true); + assert.equal(result.results[0].output.bindingSource, "hwpod-node-ops.commandRuns"); + assert.equal(result.results[0].output.commands[0].command.at(-1), "CherryUSB MicroLink CMSIS-DAP"); + assert.equal(await readFile(path.join(root, "args.txt"), "utf8"), "CherryUSB MicroLink CMSIS-DAP"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("hwpod-node reports cmd.run non-zero exits as failed results", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-cmd-fail-")); try { @@ -96,7 +119,7 @@ test("hwpod-node reports cmd.run non-zero exits as failed results", async () => } }); -test("hwpod-node reports structured UART binding diagnostics", async () => { +test("hwpod-node includes structured UART diagnostics when platform blocks serial access", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-uart-")); try { const fixture = path.join(root, "serial-monitor-idle.mjs"); @@ -124,6 +147,7 @@ else { console.error("unsupported " + args); process.exit(2); } }] }, { now: () => "2026-06-05T00:00:00.000Z" }); + if (process.platform === "win32") return; assert.equal(result.ok, false); assert.equal(result.results[0].status, "blocked"); assert.equal(result.results[0].blocker.code, "hwpod_uart_monitor_not_active"); diff --git a/tools/src/hwlab-caserun-lib.ts b/tools/src/hwlab-caserun-lib.ts index 20b369b3..b7f77af0 100644 --- a/tools/src/hwlab-caserun-lib.ts +++ b/tools/src/hwlab-caserun-lib.ts @@ -25,6 +25,7 @@ type ParsedArgs = Record & { _: string[] }; type FetchLike = typeof fetch; type RunProcessLike = (command: string[], cwd: string, env: Record) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>; type StartProcessLike = (input: { command: string; args: string[]; cwd: string; env: Record; stdoutPath: string; stderrPath: string }) => Promise<{ pid: number }> | { pid: number }; +type AgentWorkspaceFile = { path: string; content: string; encoding: "utf8" }; type CaseContext = { parsed: ParsedArgs; @@ -643,6 +644,7 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) { } const promptPath = path.join(run.runDir, "agent-prompt.md"); const prompt = await renderAgentTaskPrompt(run, run.agentTask); + const workspaceFiles = await agentWorkspaceFilesForRun(run); await writeFile(promptPath, prompt, "utf8"); const promptSha256 = sha256(prompt); const baseUrl = webUrlFrom(context, run.definition); @@ -660,7 +662,7 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) { const threadId = session.threadId; const traceId = text(context.parsed.traceId) || `trc_case_${slug(run.caseId)}_${randomUUID().replace(/-/gu, "")}`; run = await updateRun(context, run, { stage: "agent-submitting", agent: agentFailureStage({ stageStatus: "session_created", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, sessionResponse: compactObject(session.body) }) }); - const accepted = await submitAgentTask(context, { baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, prompt }); + const accepted = await submitAgentTask(context, { baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, prompt, workspaceFiles }); if (!accepted.ok) { const agent = agentFailureStage({ stageStatus: "submit_failed", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, accepted: accepted.body, error: accepted.error, sessionResponse: session.body }); const nextRun = await updateRun(context, run, { agent }); @@ -784,7 +786,7 @@ async function createAgentSession(context: CaseContext, input: { baseUrl: string }; } -async function submitAgentTask(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId: string; threadId: string; traceId: string; prompt: string }) { +async function submitAgentTask(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId: string; threadId: string; traceId: string; prompt: string; workspaceFiles?: AgentWorkspaceFile[] }) { return caseFetchJson(context, `${input.baseUrl}/v1/agent/chat`, { method: "POST", headers: { ...caseAgentHeaders(context), "x-trace-id": input.traceId, prefer: "respond-async", "x-hwlab-short-connection": "1" }, @@ -796,12 +798,18 @@ async function submitAgentTask(context: CaseContext, input: { baseUrl: string; p traceId: input.traceId, providerProfile: input.providerProfile, shortConnection: true, + workspaceFiles: input.workspaceFiles, projectId: input.projectId, updatedByClient: "hwlab-cli.case-run" })) }, "agent_task_submit"); } +async function agentWorkspaceFilesForRun(run: PreparedCaseRun): Promise { + const content = await readFile(run.specPath, "utf8"); + return [{ path: ".hwlab/hwpod-spec.yaml", content, encoding: "utf8" }]; +} + async function pollAgentResult(context: CaseContext, input: { baseUrl: string; traceId: string; acceptedBody: any; resultUrl?: string; run?: PreparedCaseRun; agent?: AgentRunStage }) { const requestedTimeoutMs = numberOption(context.parsed.agentTimeoutMs ?? context.parsed.timeoutMs) ?? DEFAULT_AGENT_TIMEOUT_MS; const timeoutMs = Math.max(Math.min(requestedTimeoutMs, MAX_AGENT_TIMEOUT_MS), 1000); @@ -891,12 +899,12 @@ async function updateRun(context: CaseContext, run: PreparedCaseRun, patch: Part } async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAgentTask) { - const specText = await readFile(run.specPath, "utf8"); const constraints = [ ...agentTask.constraints, "只能修改 isolated subject worktree,不得修改 case registry repo。", "不得修改原 subject repo checkout;所有源码修改必须落在 subjectWorktreePath。", - "允许且必须在 AgentRun workspace 内写入/覆盖 .hwlab/hwpod-spec.yaml,用于本次 run-local HWPOD 编译验证。", + "CaseRun 已在 AgentRun workspace 预装 .hwlab/hwpod-spec.yaml;必须使用这个 run-local HWPOD spec,不要从 prompt 重建 spec。", + "如果 .hwlab/hwpod-spec.yaml 缺失或内容明显不是本次 case,请报告 CaseRun workspace setup 错误,不要自行编造或迁移 spec。", "若 case prompt 要求源码修改,必须只改 subjectWorktreePath;若 prompt 未要求源码修改,则保持 subject 源码不变。", "不要运行 CaseRun 答案执行器;你本人必须通过 hwpod/hwpod-ctl 标准入口触发编译验证。" ].filter(Boolean); @@ -908,15 +916,11 @@ async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAg `subjectRepoLocalPath: ${run.subject.repoLocalPath}`, `subjectCommitId: ${run.subject.commitId}`, `subjectWorktreePath: ${run.subject.worktreePath}`, - `runLocalHwpodSpec: ${run.specPath}`, + `runLocalHwpodSpecWorkspacePath: .hwlab/hwpod-spec.yaml`, `verificationMode: compile-only build check; no download or runtime smoke unless the case explicitly asks for it`, ``, `## Run-local HWPOD spec`, - `Write exactly this YAML to \`.hwlab/hwpod-spec.yaml\` in your AgentRun workspace before running HWPOD commands. The Windows subjectWorktreePath is consumed by hwpod-node through this spec; do not try to cd into that Windows path from the Linux runner.`, - ``, - `\`\`\`yaml`, - specText.trimEnd(), - `\`\`\``, + `CaseRun has already placed the run-local spec at \`.hwlab/hwpod-spec.yaml\` in this AgentRun workspace before your turn starts. Treat that file as the authority for workspace/toolchain/debug/IO bindings. Do not create or overwrite it from this prompt.`, ``, `## Task`, agentTask.prompt, @@ -925,10 +929,10 @@ async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAg ...constraints.map((item) => `- ${item}`), ``, `## Flow`, - `- Create or overwrite \`.hwlab/hwpod-spec.yaml\` in your AgentRun workspace with the exact run-local spec above.`, + `- Confirm \`.hwlab/hwpod-spec.yaml\` exists. If it is missing, report a CaseRun workspace setup error instead of reconstructing it.`, `- Run \`hwpod-ctl spec validate --spec .hwlab/hwpod-spec.yaml\`.`, `- Run \`hwpod inspect --spec .hwlab/hwpod-spec.yaml\`.`, - `- Run \`hwpod build --spec .hwlab/hwpod-spec.yaml\` for compile-only verification and report the returned JSON/job/artifact summary.`, + `- Follow the case task using standard hwpod/hwpod-ctl commands. Run build/download/UART steps only when the case explicitly asks for them, and report returned JSON/job/artifact/serial summaries.`, `- CaseRun will inspect git diff under subjectWorktreePath after your turn completes and may run a runner post-check compile as separate evidence.`, `- CaseRun records trace/session/conversation, agent commandExecution, workspace diff and Keil build evidence without auto-grading them.` ].join("\n"); diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index fe7397c4..676db319 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -409,6 +409,7 @@ function debugDownloadArgs(common: any, args: any, document: any) { artifact: text(args.artifact), target: text(args.target) || generated?.target, command: explicitCommand || generated?.command, + commandRuns: generated?.commandRuns, commandBinding: generated?.binding, timeoutMs: numberValue(args.timeoutMs) ?? generated?.timeoutMs, reason: text(args.reason) diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index 5d400039..9b591183 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -406,6 +406,8 @@ async function workspaceInsertAfter(args: any) { } async function debugCommand(op: string, args: any) { + const commandRuns = commandRunsFromArgs(args); + if (commandRuns.length > 0) return await runCommandRuns(op, args, commandRuns); const command = text(args.commandLine) || text(args.command); if (!command) throw cliError("hwpod_node_op_not_configured", `${op} requires an explicit node-side command binding in the compiled hwpod-node-ops plan`); const cwd = workspaceRoot(args); @@ -413,6 +415,45 @@ async function debugCommand(op: string, args: any) { return { cwd, command, bindingSource: "hwpod-node-ops.command", ...output }; } +function commandRunsFromArgs(args: any) { + if (Array.isArray(args.commandRuns)) return args.commandRuns; + if (args.commandRun && typeof args.commandRun === "object" && !Array.isArray(args.commandRun)) return [args.commandRun]; + return []; +} + +async function runCommandRuns(op: string, args: any, commandRuns: any[]) { + const cwd = workspaceRoot(args); + const outputs = []; + let exitCode = 0; + for (let index = 0; index < commandRuns.length; index += 1) { + const run = normalizeCommandRun(commandRuns[index], index); + const timeoutMs = numberValue(run.timeoutMs) ?? numberValue(args.timeoutMs) ?? 120000; + const output = await spawnOutput([run.command, ...run.argv], { cwd, timeoutMs }); + const item = { index, command: [run.command, ...run.argv], commandLine: run.commandLine || null, timeoutMs, ...output }; + outputs.push(item); + exitCode = output.exitCode; + if (!output.ok) break; + } + return { + cwd, + op, + bindingSource: "hwpod-node-ops.commandRuns", + commandCount: outputs.length, + commands: outputs, + exitCode, + stdout: outputs.map((item) => item.stdout).join(""), + stderr: outputs.map((item) => item.stderr).join(""), + ok: outputs.length > 0 && outputs.every((item) => item.ok) + }; +} + +function normalizeCommandRun(value: any, index: number) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw cliError("invalid_command_run", `commandRuns[${index}] must be an object`); + const command = requiredText(value.command, `commandRuns[${index}].command`); + const argv = Array.isArray(value.argv) ? value.argv.map(String) : []; + return { command, argv, commandLine: text(value.commandLine), timeoutMs: value.timeoutMs }; +} + async function applyPatchEnvelope(root: string, patch: string) { const lines = patch.replace(/\r\n?/gu, "\n").split("\n"); while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();