mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
feat(signals): decode recordings with bounded sigrok output (#2339)
Decode caller-selected protocols from existing recordings through shared CLI/MCP Evidence. Page literal output lines after complete decoding; preserve explicit full output, source identity and failed cleanup ownership. Verified real sigrok-cli 0.7.2 workflows, published UART/SPI/I2C recordings, independent UART values, selector limits, SDK schemas and cleanup regressions. Co-authored-by: Olorinm <59321404+Olorinm@users.noreply.github.com> Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
@@ -31,6 +31,7 @@ export const toolFamilyCatalog = (sources) => {
|
||||
...sources.wasmContracts.WASM_TOOL_CONTRACTS,
|
||||
...sources.sqliteContracts.SQLITE_TOOL_CONTRACTS,
|
||||
...sources.captureContracts.CAPTURE_TOOL_CONTRACTS,
|
||||
...sources.signalContracts.SIGNAL_TOOL_CONTRACTS,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -201,6 +202,10 @@ export const providerCatalog = (sources) => {
|
||||
identity: sources.captureProvider.TSHARK_PROVIDER_IDENTITY,
|
||||
contracts: sources.captureContracts.CAPTURE_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.signalProvider.SIGROK_PROVIDER_IDENTITY,
|
||||
contracts: sources.signalContracts.SIGNAL_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.wasmProvider.WABT_PROVIDER_IDENTITY,
|
||||
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS,
|
||||
|
||||
@@ -49,6 +49,8 @@ const SOURCE_PATHS = {
|
||||
nativeContracts: "dist/contracts/native/nativeToolContracts.js",
|
||||
captureContracts: "dist/contracts/captures/captureToolContracts.js",
|
||||
captureProvider: "dist/captures/TsharkCaptureProvider.js",
|
||||
signalContracts: "dist/contracts/signals/signalToolContracts.js",
|
||||
signalProvider: "dist/signals/SigrokRecordingProvider.js",
|
||||
wasmContracts: "dist/contracts/wasm/wasmToolContracts.js",
|
||||
wasmProvider: "dist/wasm/WabtRelease.js",
|
||||
evmContracts: "dist/contracts/evm/evmToolContracts.js",
|
||||
|
||||
@@ -10,6 +10,7 @@ const MIGRATED_PROVIDER_ROOTS = new Set([
|
||||
"go",
|
||||
"wasm",
|
||||
"captures",
|
||||
"signals",
|
||||
"android",
|
||||
"firmware",
|
||||
"ghidra",
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, readdir } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
if (process.platform !== "linux")
|
||||
throw new Error(
|
||||
"Real signal verification requires Linux with util-linux prlimit.",
|
||||
);
|
||||
if (!isAbsolute(process.env.REA_SIGROK_COMMAND ?? ""))
|
||||
throw new Error(
|
||||
"Supply an absolute REA_SIGROK_COMMAND for sigrok-cli 0.7.2.",
|
||||
);
|
||||
const entrypoint =
|
||||
process.argv[2] ?? fileURLToPath(new URL("./rea.mjs", import.meta.url));
|
||||
const run = createVerifierRun();
|
||||
const root = await PrivateRuntimeRoot.create({
|
||||
prefix: "rea-signal-verifier-",
|
||||
});
|
||||
const path = join(root.path, "recording with spaces.vcd");
|
||||
const pagedPath = join(root.path, "paged.vcd");
|
||||
const execute = promisify(execFile);
|
||||
const environment = {
|
||||
...process.env,
|
||||
TMPDIR: root.path,
|
||||
TMP: root.path,
|
||||
TEMP: root.path,
|
||||
};
|
||||
const client = new Client({ name: "signal-recording-verifier", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
// Independently synthesize 9600-baud 8N1 samples at 1 MHz: bytes 52 45 41.
|
||||
const lines = [
|
||||
"$timescale 1 us $end",
|
||||
"$scope module logic $end",
|
||||
"$var wire 1 ! RX $end",
|
||||
"$upscope $end",
|
||||
"$enddefinitions $end",
|
||||
"#0",
|
||||
"1!",
|
||||
];
|
||||
let tick = 1000;
|
||||
for (const byte of Buffer.from("REA")) {
|
||||
for (const bit of [
|
||||
0,
|
||||
...Array.from({ length: 8 }, (_, i) => (byte >> i) & 1),
|
||||
1,
|
||||
]) {
|
||||
lines.push(`#${tick}`, `${bit}!`);
|
||||
tick += 104;
|
||||
}
|
||||
tick += 208;
|
||||
}
|
||||
lines.push(`#${tick + 1000}`, "1!");
|
||||
const source = lines.join("\n") + "\n";
|
||||
let cases = 0;
|
||||
try {
|
||||
await writeFile(path, source);
|
||||
await client.connect(transport);
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
({ name }) => name === "decode_signal_recording",
|
||||
);
|
||||
assert.ok(advertised?.outputSchema);
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
assert.ok(ajv.validateSchema(advertised.inputSchema));
|
||||
assert.ok(ajv.validateSchema(advertised.outputSchema));
|
||||
for (const extra of [
|
||||
{ input_format: "vcd", annotations: "uart=rx-data" },
|
||||
{},
|
||||
]) {
|
||||
const input = { path, decoders: "uart:rx=RX:baudrate=9600", ...extra };
|
||||
const response = await client.callTool({
|
||||
name: advertised.name,
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
assert.ok(
|
||||
ajv.validate(advertised.outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
);
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
const report = evidence.normalized_result;
|
||||
for (const byte of ["52", "45", "41"])
|
||||
assert.match(
|
||||
report.annotations_text,
|
||||
new RegExp(`\\d+-\\d+ uart-1: ${byte}\\n`),
|
||||
);
|
||||
assert.equal(
|
||||
report.artifact.sha256,
|
||||
createHash("sha256").update(source).digest("hex"),
|
||||
);
|
||||
assert.equal(report.hardware_acquisition, "not-performed");
|
||||
assert.equal(report.limits.address_space_bytes, 1073741824);
|
||||
assert.equal(report.tool.configured_path, process.env.REA_SIGROK_COMMAND);
|
||||
assert.equal(
|
||||
report.resource_limiter.configured_path,
|
||||
process.env.REA_SIGROK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
|
||||
);
|
||||
assert.ok(
|
||||
report.resource_limiter.arguments.includes("--as=1073741824:1073741824"),
|
||||
);
|
||||
assert.equal(report.diagnostics, "");
|
||||
const flags = Object.entries(input)
|
||||
.filter(([key]) => key !== "path")
|
||||
.flatMap(([key, value]) => [`--${key}`, value]);
|
||||
const cli = JSON.parse(
|
||||
(
|
||||
await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"decode-signal-recording",
|
||||
path,
|
||||
...flags,
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{ env: environment, timeout: 60000, maxBuffer: 16 * 1024 * 1024 },
|
||||
)
|
||||
).stdout,
|
||||
);
|
||||
assert.equal(parseEvidence(cli).evidence_id, evidence.evidence_id);
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
assert.ok(JSON.stringify(bundle).includes(evidence.evidence_id));
|
||||
assert.equal(await readFile(path, "utf8"), source);
|
||||
cases++;
|
||||
}
|
||||
const pagedLines = lines.slice(0, 7);
|
||||
let pagedTick = 1000;
|
||||
for (const byte of Array.from({ length: 120 }, (_, i) => i)) {
|
||||
for (const bit of [
|
||||
0,
|
||||
...Array.from({ length: 8 }, (_, i) => (byte >> i) & 1),
|
||||
1,
|
||||
]) {
|
||||
pagedLines.push(`#${pagedTick}`, `${bit}!`);
|
||||
pagedTick += 104;
|
||||
}
|
||||
pagedTick += 208;
|
||||
}
|
||||
pagedLines.push(`#${pagedTick + 1000}`, "1!");
|
||||
const pagedSource = pagedLines.join("\n") + "\n";
|
||||
await writeFile(pagedPath, pagedSource);
|
||||
for (const [options, first, count] of [
|
||||
[{}, 0, 50],
|
||||
[{ line_offset: 50, line_limit: 40 }, 50, 40],
|
||||
[{ line_limit: null }, 0, 120],
|
||||
]) {
|
||||
const input = {
|
||||
path: pagedPath,
|
||||
decoders: "uart:rx=RX:baudrate=9600",
|
||||
input_format: "vcd",
|
||||
annotations: "uart=rx-data",
|
||||
...options,
|
||||
};
|
||||
const response = await client.callTool({
|
||||
name: advertised.name,
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
assert.ok(
|
||||
ajv.validate(advertised.outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
);
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
const report = evidence.normalized_result;
|
||||
assert.deepEqual(report.output_window, {
|
||||
total_lines: 120,
|
||||
returned_lines: count,
|
||||
has_more: first + count < 120,
|
||||
});
|
||||
const observed = report.annotations_text
|
||||
.trimEnd()
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const match = /^\d+-\d+ uart-1: ([0-9A-F]{2})$/.exec(line);
|
||||
assert.ok(match, line);
|
||||
return Number.parseInt(match[1], 16);
|
||||
});
|
||||
assert.deepEqual(
|
||||
observed,
|
||||
Array.from({ length: count }, (_, i) => first + i),
|
||||
);
|
||||
const flags = Object.entries(input)
|
||||
.filter(([key]) => key !== "path")
|
||||
.flatMap(([key, value]) =>
|
||||
key === "line_limit" && value === null
|
||||
? ["--all_lines"]
|
||||
: [`--${key}`, String(value)],
|
||||
);
|
||||
const cli = JSON.parse(
|
||||
(
|
||||
await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"decode-signal-recording",
|
||||
pagedPath,
|
||||
...flags,
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{ env: environment, timeout: 60000, maxBuffer: 16 * 1024 * 1024 },
|
||||
)
|
||||
).stdout,
|
||||
);
|
||||
assert.equal(parseEvidence(cli).evidence_id, evidence.evidence_id);
|
||||
assert.equal(await readFile(pagedPath, "utf8"), pagedSource);
|
||||
cases++;
|
||||
}
|
||||
for (const input of [
|
||||
{ path, decoders: "no_such_decoder" },
|
||||
{ path: join(root.path, "absent.vcd"), decoders: "uart" },
|
||||
{ path, decoders: "uart", driver: "demo" },
|
||||
]) {
|
||||
const response = await client.callTool({
|
||||
name: advertised.name,
|
||||
arguments: input,
|
||||
});
|
||||
assert.equal(response.isError, true);
|
||||
await client.ping();
|
||||
cases++;
|
||||
}
|
||||
await writeFile(path, "not a recording");
|
||||
const invalid = await client.callTool({
|
||||
name: advertised.name,
|
||||
arguments: { path, decoders: "uart" },
|
||||
});
|
||||
assert.equal(invalid.isError, true);
|
||||
cases++;
|
||||
await client.close();
|
||||
assert.deepEqual((await readdir(root.path)).sort(), [
|
||||
"paged.vcd",
|
||||
"recording with spaces.vcd",
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
await root.close();
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
status: "passed",
|
||||
cases,
|
||||
host: `${process.platform}/${process.arch}`,
|
||||
scope:
|
||||
"real sigrok UART VCD through compiled CLI/MCP; no physical acquisition",
|
||||
...(await completeVerifierRun(run)),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user