fix(v0.2): device-pod output.text evidence read-only selectors for #773

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
This commit is contained in:
Codex
2026-06-03 21:29:14 +08:00
parent 867f8dd751
commit eb5bada0d1
6 changed files with 153 additions and 11 deletions
+26
View File
@@ -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 [
+31
View File
@@ -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) => {
+35 -2
View File
@@ -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,
+7 -6
View File
@@ -49,6 +49,8 @@ listed, that is a missing intent - file an issue instead of guessing.
| --- | --- | --- | --- |
| `workspace` | `/` or `<relpath>` | `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` | `:<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 <TargetName>]` | 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 <TargetName>]` | Read-only. Returns the most recent (or specified) keil-download log tail. No `--reason` needed. |
| `io-probe` | `:/uart/<n>` | `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 "<short text>"`
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 "<short text>"`
for `build start` and `download start`:
```sh
# ok
@@ -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 <empty-dir>',
'node tools/device-host-cli.mjs workspace build clean [--target <TargetName>]',
'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 <TargetName>]',
'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 <TargetName>]',
'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 <method> [--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));
+6 -3
View File
@@ -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 <jobId>"
],
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: <pod>:workspace:/ build evidence [jobId] and <pod>: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/<n>)",
"common mistakes: workspace keil download/flash and debug-probe flash are not SOP; use <pod>:debug-probe download start",
"common mistakes: workspace keil download/flash and debug-probe flash are not SOP; use <pod>: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"
]
});