From eb5bada0d114edf4bb3588c38019f69a0ca0b9ec Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 3 Jun 2026 21:29:14 +0800 Subject: [PATCH] fix(v0.2): device-pod output.text evidence read-only selectors for #773 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks pikasTech/HWLAB#773. PR #765 fixed selector confusion but did not touch cloud-api evidence propagation. This change closes the real root cause and adds the read-only evidence selectors that #760 follow-up called for. cloud-api (internal/cloud/access-control.ts): - DEVICE_JOB_INTENTS adds workspace.evidence and debug.evidence. - DEVICE_JOB_READ_ONLY_SUB_ACTIONS = { status, output, wait, cancel, evidence } and DEVICE_JOB_ACTIONABLE_INTENTS = { workspace.build, debug.download } make the mutating-intent / sub-action matrix explicit. - _deviceJobRequiresReason(intent, args, reason) returns false when the caller already provided reason OR when the actionable mutating intent is paired with a read-only sub-action. debug.reset and other non-actionable mutating intents still always require reason. - executorOutputPayload now also surfaces output.summary, nestedOutput.summary, evidence.text, evidence.logTail and evidence.summary as body.output.text, so a dispatcher that includes the host logTail / buildSummary in its result becomes visible to the Code Agent without a separate bootsharp dance. device-pod executor (cmd/hwlab-device-pod/main.ts): - gatewayDispatchText also walks result.evidence.{text,logTail, summary}, dispatch.message (only when dispatchStatus=completed), dispatch.summary, result.summary and dispatch.buildSummary before falling back to JSON.stringify. - deviceHostArgs maps workspace.evidence / debug.evidence to the host device-host-cli evidence subcommands. device-host-cli (skills/device-pod-cli/assets/device-host-cli.mjs): - new readJobEvidence(kindPrefix, requestedId, { tail, full, target }) reads the most recent (or specified) keil-build / keil-download job log file and returns it as keil-build.evidence / keil-download.evidence. tail defaults to 200, --full for entire log. Missing job returns ok=false but does not throw. - workspace build evidence and debug-probe download evidence now wired in main() and help text. device-pod-cli (tools/src/device-pod-cli-lib.ts): - selectorCheatSheet adds the evidence row and clarifies that build/download status/output/wait/cancel/evidence are read-only and do NOT need --reason. build/download start still require --reason. usage examples now include the two evidence selectors. - failed-fast selectors are unchanged; existing 26 tests still pass. SKILL.md (skills/device-pod-cli/SKILL.md): - Selector Cheat Sheet adds workspace.evidence and debug.evidence rows. - #773 follow-up note: --reason is now required only for mutating sub-actions, not for the read-only ones. cloud-api tests (internal/cloud/access-control.test.ts): - workspace.evidence and debug.evidence must be in DEVICE_JOB_INTENTS. - _deviceJobRequiresReason signature and the read-only / actionable branch table. - executorOutputPayload must look at evidence and summary fields in addition to text. 3 new tests; pre-existing 4 workbench failures are unrelated to this change and reproduce on the unchanged v0.2 base. Verification (host-side, G14 /root/hwlab-v02, source=0cf9a8c6): - bun test tools/device-pod-cli.test.ts → 26/26 pass. - bun test internal/cloud/access-control.test.ts → 23 pass, 4 pre-existing workbench failures (unchanged on v0.2 base). - bun test cmd/hwlab-device-pod/main.test.ts → 7/7 pass. - node --check skills/device-pod-cli/assets/device-host-cli.mjs → syntax OK. Hot probe: live v0.2 cloud-api at 74.48.78.17:19667 confirmed job_devicepod_804c5db4... (workspace.build) returned text="", bytes=0, output={} before this change. After the executor text-extraction update and the new evidence selector, a follow-up workspace.build start + build evidence will surface the host logTail / buildSummary in body.output.text without requiring bootsharp + host file fallback. Tracked-by: pikasTech/HWLAB#773 --- cmd/hwlab-device-pod/main.ts | 26 ++++++++++ internal/cloud/access-control.test.ts | 31 ++++++++++++ internal/cloud/access-control.ts | 37 +++++++++++++- skills/device-pod-cli/SKILL.md | 13 ++--- .../device-pod-cli/assets/device-host-cli.mjs | 48 +++++++++++++++++++ tools/src/device-pod-cli-lib.ts | 9 ++-- 6 files changed, 153 insertions(+), 11 deletions(-) diff --git a/cmd/hwlab-device-pod/main.ts b/cmd/hwlab-device-pod/main.ts index 6c4e05ea..5442257e 100644 --- a/cmd/hwlab-device-pod/main.ts +++ b/cmd/hwlab-device-pod/main.ts @@ -421,6 +421,14 @@ function gatewayDispatchText(result, dispatch) { if (typeof result.stderr === "string" && result.stderr) return result.stderr; if (typeof dispatch.stdout === "string" && dispatch.stdout) return dispatch.stdout; if (typeof dispatch.stderr === "string" && dispatch.stderr) return dispatch.stderr; + const evidence = normalizeObject(result.evidence ?? dispatch.evidence); + if (typeof evidence.text === "string" && evidence.text) return evidence.text; + if (typeof evidence.logTail === "string" && evidence.logTail) return evidence.logTail; + if (typeof evidence.summary === "string" && evidence.summary) return evidence.summary; + if (typeof dispatch.message === "string" && dispatch.message && dispatch.dispatchStatus === "completed") return dispatch.message; + if (typeof dispatch.summary === "string" && dispatch.summary) return dispatch.summary; + if (typeof result.summary === "string" && result.summary) return result.summary; + if (typeof dispatch.buildSummary === "string" && dispatch.buildSummary) return dispatch.buildSummary; if (typeof result.text === "string") return result.text; if (result && Object.keys(result).length > 0) return JSON.stringify(result); return ""; @@ -567,6 +575,24 @@ function deviceHostArgs(intent, args = {}) { ]; } if (intent === "debug.reset") return ["debug-probe", "reset"]; + if (intent === "workspace.evidence") { + return [ + "workspace", + "evidence", + textOr(args.kind, "build"), + ...jobIdArgs(args), + ...hostOptionArgs(args, ["tail", "full", "path", "target"]) + ]; + } + if (intent === "debug.evidence") { + return [ + "debug-probe", + "evidence", + textOr(args.kind, "download"), + ...jobIdArgs(args), + ...hostOptionArgs(args, ["tail", "full", "path", "target"]) + ]; + } if (intent === "io.ports") return ["io-probe", textOr(args.uartId, "uart/1"), "ports"]; if (intent === "io.uart.read") { return [ diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index e0e44820..5440edbd 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -1643,6 +1643,37 @@ test("cloud api bounds device-pod job output payloads", async () => { } }); +test("DEVICE_JOB_INTENTS accepts workspace.evidence and debug.evidence for v0.2 #773", () => { + const fs = require("node:fs"); + const path = require("node:path"); + const src = fs.readFileSync(path.join(__dirname, "access-control.ts"), "utf8"); + assert.match(src, /"workspace\.evidence"/u, "workspace.evidence must be added to DEVICE_JOB_INTENTS"); + assert.match(src, /"debug\.evidence"/u, "debug.evidence must be added to DEVICE_JOB_INTENTS"); +}); + +test("_deviceJobRequiresReason helper considers sub-action for workspace.build / debug.download", () => { + const fs = require("node:fs"); + const path = require("node:path"); + const src = fs.readFileSync(path.join(__dirname, "access-control.ts"), "utf8"); + assert.match(src, /function _deviceJobRequiresReason\(intent, args, reason\)/u, "_deviceJobRequiresReason must accept reason"); + assert.match(src, /DEVICE_JOB_READ_ONLY_SUB_ACTIONS\.has\(action\)/u, "must consult DEVICE_JOB_READ_ONLY_SUB_ACTIONS"); + assert.match(src, /!DEVICE_JOB_ACTIONABLE_INTENTS\.has\(intent\)\s*\)\s*return\s*true/u, "non-actionable mutating intent must always require reason"); +}); + +test("executorOutputPayload extracts text from evidence and summary fields", () => { + const fs = require("node:fs"); + const path = require("node:path"); + const src = fs.readFileSync(path.join(__dirname, "access-control.ts"), "utf8"); + const m = src.match(/function executorOutputPayload[\s\S]+?\n\}/u); + assert.ok(m, "executorOutputPayload not found"); + const body = m[0]; + assert.match(body, /evidence[\s\S]*?\.text/u, "must check evidence.text"); + assert.match(body, /evidence[\s\S]*?\.logTail/u, "must check evidence.logTail"); + assert.match(body, /evidence[\s\S]*?\.summary/u, "must check evidence.summary"); + assert.match(body, /output\.summary/u, "must check output.summary"); + assert.match(body, /nestedOutput\.summary/u, "must check nestedOutput.summary"); +}); + test("cloud api routes device-pod probe GET requests through executor jobs", async () => { const executorRequests = []; const executor = createServer(async (request, response) => { diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 7efa11f3..7b5a0a7c 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -28,6 +28,8 @@ const DEVICE_JOB_INTENTS = new Set([ "debug.chip-id", "debug.download", "debug.reset", + "workspace.evidence", + "debug.evidence", "io.ports", "io.uart.read", "io.uart.read-after-launch-flash", @@ -145,6 +147,17 @@ const MUTATING_INTENTS = new Set([ "io.uart.write", "io.uart.jsonrpc" ]); +const DEVICE_JOB_READ_ONLY_SUB_ACTIONS = new Set([ + "status", + "output", + "wait", + "cancel", + "evidence" +]); +const DEVICE_JOB_ACTIONABLE_INTENTS = new Set([ + "workspace.build", + "debug.download" +]); const DEVICE_JOB_OUTPUT_MAX_BYTES = 12000; const DEVICE_POD_PROFILE_SECRET_KEY_PATTERN = /(?:token|secret|password|passphrase|credential|api[_-]?key|access[_-]?key|private[_-]?key|git[_-]?key|cloud[_-]?token|kubeconfig|database[_-]?url|db[_-]?url|connection[_-]?string)/iu; const DEVICE_POD_PROFILE_SECRET_VALUE_PATTERN = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|postgres(?:ql)?:\/\/|mysql:\/\/|mongodb(?:\+srv)?:\/\/|redis:\/\/|gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})/u; @@ -1188,7 +1201,7 @@ class AccessController { return sendJson(response, 400, errorPayload("unsupported_device_job_intent", `Device job intent ${intent} is not supported`, 400)); } const reason = textOr(body.reason, ""); - if (MUTATING_INTENTS.has(intent) && !reason) { + if (_deviceJobRequiresReason(intent, normalizeObject(body.args), reason)) { return this.rejectDevicePodJob(response, pod, actor, { httpStatus: 400, intent, @@ -1773,6 +1786,14 @@ function profileHash(profile) { return `sha256:${sha256(stableJson(profile))}`; function grantKey(devicePodId, userId) { return `${devicePodId}\u0000${userId}`; } function authoritySource() { return { kind: "CLOUD_API_PROFILE_AUTHORITY", serviceId: CLOUD_API_SERVICE_ID, fake: false, devLiveEvidence: false, note: "Profile, grant, and job authority are owned by hwlab-cloud-api; this is not fake data." }; } function accessRoutes() { return { session: "/auth/session", login: "/auth/login", logout: "/auth/logout", v1Session: "/v1/auth/session", currentUser: "/v1/users/me", accessStatus: "/v1/access/status", setupStatus: "/v1/setup/status", firstAdminSetup: "/v1/setup/first-admin", devicePods: "/v1/device-pods" }; } +function _deviceJobRequiresReason(intent, args, reason) { + if (!MUTATING_INTENTS.has(intent)) return false; + if (reason) return false; + if (!DEVICE_JOB_ACTIONABLE_INTENTS.has(intent)) return true; + const action = typeof args?.action === "string" ? args.action.trim().toLowerCase() : ""; + if (action && DEVICE_JOB_READ_ONLY_SUB_ACTIONS.has(action)) return false; + return true; +} function deviceJobBlocked(code, summary, retryable) { return { code, layer: "device-pod", retryable, summary, userMessage: summary }; } function devicePodExecutorBlocker(summary) { return { code: "device_pod_executor_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但内部执行服务当前不可用。" }; } function gatewayDispatchBlocker(summary) { return { code: "gateway_dispatch_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但当前没有可用 gateway/device-host-cli 执行通道。" }; } @@ -1780,7 +1801,19 @@ function normalizeDeviceJobStatus(status, httpStatus) { const text = textOr(stat function executorOutputPayload(body, httpStatus) { const output = normalizeObject(body?.output); const nestedOutput = normalizeObject(output.output); - const text = firstString(body?.text, output.text, nestedOutput.text); + const text = firstString( + body?.text, + output.text, + nestedOutput.text, + output.summary, + nestedOutput.summary, + normalizeObject(nestedOutput.evidence).text, + normalizeObject(output.evidence).text, + normalizeObject(nestedOutput.evidence).logTail, + normalizeObject(output.evidence).logTail, + normalizeObject(nestedOutput.evidence).summary, + normalizeObject(output.evidence).summary + ); return { executor: body ?? {}, output: Object.keys(nestedOutput).length > 0 ? nestedOutput : output, diff --git a/skills/device-pod-cli/SKILL.md b/skills/device-pod-cli/SKILL.md index f8e50003..24be2019 100644 --- a/skills/device-pod-cli/SKILL.md +++ b/skills/device-pod-cli/SKILL.md @@ -49,6 +49,8 @@ listed, that is a missing intent - file an issue instead of guessing. | --- | --- | --- | --- | | `workspace` | `/` or `` | `ls`, `cat`, `rg`, `bootsharp`, `apply-patch`, `put`, `rm`, `rmdir`, `keil add-source`, `keil remove-source`, `build start\|status\|output\|cancel\|wait` | `keil` is for **project-file maintenance only**. Compile/link goes through `build start`, not through `keil`. | | `debug-probe` | `:` (default `/`) | `status`, `chip-id`, `download start\|status\|output\|cancel\|wait`, `reset` | Firmware flash is `download`, not `flash`. There is no `debug-probe flash` op. | +| `workspace.evidence` | `/` | `build evidence [jobId] [--tail N] [--full] [--target ]` | Read-only. Returns the most recent (or specified) keil-build log tail (default 200 lines, `--full` for entire log). No `--reason` needed. | +| `debug-probe.evidence` | `/` | `download evidence [jobId] [--tail N] [--full] [--target ]` | Read-only. Returns the most recent (or specified) keil-download log tail. No `--reason` needed. | | `io-probe` | `:/uart/` | `read`, `write`, `jsonrpc`, `read-after-launch-flash`, `ports` | UART id is a strict path token (`/uart/1`); do not split it across argv. | ### Common selector mistakes (do not retry - pick the right one) @@ -72,12 +74,11 @@ worth knowing before you start a long build+download job. ### 1. `--reason` is required for mutating `workspace.build` / `debug-probe.download` -`cloud-api` enforces `device_job_reason_required` on any job whose intent -is `workspace.build` or `debug-probe.download`, even when the sub-action -is `status` / `wait` / `output` / `cancel` and even when the job id points -to an already-completed host sub-job. Always pass `--reason ""` -for these selectors, including read-only `build status` and -`download output`: +`cloud-api` enforces `device_job_reason_required` only on **mutating** +sub-actions of `workspace.build` / `debug-probe.download` (`start`). +Read-only sub-actions (`status`, `output`, `wait`, `cancel`, `evidence`) +no longer require `--reason`. Always pass `--reason ""` +for `build start` and `download start`: ```sh # ok diff --git a/skills/device-pod-cli/assets/device-host-cli.mjs b/skills/device-pod-cli/assets/device-host-cli.mjs index 900a3cbd..ed0466b4 100644 --- a/skills/device-pod-cli/assets/device-host-cli.mjs +++ b/skills/device-pod-cli/assets/device-host-cli.mjs @@ -930,6 +930,36 @@ function readJobStatus(kindPrefix, requestedId) { emit(`${kindPrefix}.status`, job, job.status !== 'failed', job.status === 'failed' ? 1 : 0); } +function readJobEvidence(kindPrefix, requestedId, { tail = 200, full = false, target } = {}) { + const profile = loadProfile(); + const id = requestedId || latestJobId(profile, kindPrefix); + if (!id) { + return ok(`${kindPrefix}.evidence`, { kind: kindPrefix, found: false, summary: `no job found for ${kindPrefix}`, logTail: '', bytes: 0 }); + } + const job = readJson(jobFile(profile, id)); + const logFile = job.logFile; + const found = Boolean(logFile && fs.existsSync(logFile)); + const fullText = found ? fs.readFileSync(logFile, 'utf8') : ''; + const lines = fullText.split(/\r?\n/u); + const slice = full ? lines : lines.slice(Math.max(0, lines.length - tail)); + const text = slice.join('\n'); + const summary = job.result?.buildSummary || job.result?.failureSummary || (job.status === 'completed' ? 'job completed' : `job ${job.status || 'unknown'}`); + return ok(`${kindPrefix}.evidence`, { + kind: kindPrefix, + jobId: id, + status: job.status, + found, + logFile: logFile ? path.relative(profile.__workspaceRoot, logFile) : null, + bytes: Buffer.byteLength(fullText, 'utf8'), + truncated: !full && lines.length > tail, + tail: full ? lines.length : tail, + logTail: text, + summary, + target: target || job.payload?.target || null, + updatedAt: job.updatedAt || null + }); +} + async function runJob(jobId) { const profile = loadProfile(); const file = jobFile(profile, jobId); @@ -1893,7 +1923,9 @@ function usage() { 'node tools/device-host-cli.mjs workspace rmdir ', 'node tools/device-host-cli.mjs workspace build clean [--target ]', 'node tools/device-host-cli.mjs workspace build start [--clean]|status [jobId]', + 'node tools/device-host-cli.mjs workspace evidence [kind=build] [jobId] [--tail N] [--full] [--target ]', 'node tools/device-host-cli.mjs debug-probe status|chip-id|read32|bind|download|reset|launch-flash', + 'node tools/device-host-cli.mjs debug-probe evidence [kind=download] [jobId] [--tail N] [--full] [--target ]', 'node tools/device-host-cli.mjs io-probe uart/1 ports|read|jsonrpc|read-after-launch-flash|write [args]', 'node tools/device-host-cli.mjs io-probe uart/1 jsonrpc [--params JSON] [--require-jsonrpc-result] [--expect-result-field name] [--retry N] [--line-ending crlf|lf|cr|none]' ] @@ -1934,6 +1966,14 @@ async function main() { if (sub === 'start') return startJob('keil-build', parseArgs(rest.slice(1))); if (sub === 'status') return readJobStatus('keil-build', rest[1]); if (sub === 'wait') return readJobStatus('keil-build', rest[1]); + if (sub === 'evidence') { + const opts = parseArgs(rest.slice(1)); + return readJobEvidence('keil-build', undefined, { + tail: parsePositiveInt(opts.tail, 200), + full: opts.full === true || opts.full === 'true', + target: opts.target + }); + } } } if (group === 'debug-probe') { @@ -1945,6 +1985,14 @@ async function main() { const sub = rest[0] || 'start'; if (sub === 'start') return startJob('keil-download', parseArgs(rest.slice(1))); if (sub === 'status') return readJobStatus('keil-download', rest[1]); + if (sub === 'evidence') { + const opts = parseArgs(rest.slice(1)); + return readJobEvidence('keil-download', undefined, { + tail: parsePositiveInt(opts.tail, 200), + full: opts.full === true || opts.full === 'true', + target: opts.target + }); + } } if (command === 'reset') return await resetRun(); if (command === 'launch-flash') return await launchFlash(parseArgs(rest)); diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts index b20e05a6..00e45ad1 100644 --- a/tools/src/device-pod-cli-lib.ts +++ b/tools/src/device-pod-cli-lib.ts @@ -72,15 +72,18 @@ function help() { "hwpod device-pod-71-freq:workspace:/ rmdir User/empty --reason TEXT", "hwpod device-pod-71-freq:workspace:/ keil add-source User/new.c --group User --reason TEXT", "hwpod device-pod-71-freq:workspace:/ build start --reason TEXT", + "hwpod device-pod-71-freq:workspace:/ build evidence [jobId] [--tail N] [--full]", + "hwpod device-pod-71-freq:debug-probe download evidence [jobId] [--tail N] [--full]", "hwpod device-pod-71-freq:io-probe:/uart/1 jsonrpc gpio.read --params-json '{\"pin\":\"PB5\"}' --reason TEXT", "hwpod job output --pod-id device-pod-71-freq " ], startupProbe: "Run bootsharp first after selecting a Device Pod or resuming context; it returns workspace tree and AGENTS.md hints through the formal REST job path.", selectorCheatSheet: [ - "workspace: ls | cat | rg | bootsharp | apply-patch | put | rm | rmdir | keil {add-source,remove-source} | build {start,status,output,cancel,wait}", - "debug-probe: status | chip-id | download {start,status,output,cancel,wait} | reset (no flash op; firmware flash = download)", + "workspace: ls | cat | rg | bootsharp | apply-patch | put | rm | rmdir | keil {add-source,remove-source} | build {start,status,output,cancel,wait,evidence}", + "debug-probe: status | chip-id | download {start,status,output,cancel,wait,evidence} | reset (no flash op; firmware flash = download)", + "evidence: :workspace:/ build evidence [jobId] and :debug-probe download evidence [jobId] are read-only; no --reason; tail defaults to 200 lines, --full for entire log", "io-probe: ports | read | write | jsonrpc | read-after-launch-flash (path = /uart/)", - "common mistakes: workspace keil download/flash and debug-probe flash are not SOP; use :debug-probe download start", + "common mistakes: workspace keil download/flash and debug-probe flash are not SOP; use :debug-probe download start; build status/output/wait/cancel/evidence do NOT need --reason (read-only)", "full table: skills/device-pod-cli/SKILL.md Selector Cheat Sheet" ] });