fix(npm): address 10 verified review findings in harness + rvagent before 0.2.0 publish

harness/ruview (@ruvnet/ruview):
- guardrails: digit gate now sees numbers inside code spans; F1-style
  metric tokens followed by ':' or a nearby number are no longer scrubbed
  (fail-open regressions in the honesty gate)
- mcp-server: tools/call requests serialize through a FIFO promise chain
  (hardware/mutating tools never overlap) while ping/tools/list stay
  immediate; stdin close drains in-flight responses before exit
- tools: which() no longer memoizes negative lookups

tools/ruview-mcp (@ruvnet/rvagent):
- index: realpath invoked-directly guard — library import no longer
  connects a stdio transport to the consumer's process
- http-transport: explicit allowedOrigins is exact-match only (localhost
  any-port convenience applies only with no configured allowlist);
  session map gains maxSessions=64 + 5min idle TTL sweep
- train-count: job records persist the child pid and reconcile stale
  'running' status after a server restart (exit-code marker or dead pid)
- config: cog binary candidates ordered by process.arch

.github/workflows/ruview-npm-release.yml: port the full ADR-265 D1 gate
(version-literal check, unpacked-size budget, tarball-install smoke test)
from npm-packages.yml so the publish path enforces what the header claims.

Tests: harness 30→36, rvagent 99→112, all passing.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-07-02 09:58:00 -04:00
parent d26fb0b8fc
commit d46eb8a4b2
15 changed files with 651 additions and 44 deletions
+49
View File
@@ -0,0 +1,49 @@
/**
* ADR-264 F8/O7 — cog-binary detection must be architecture-aware.
*
* detectCogBinary() itself probes hardcoded /var/lib paths, so it is not
* cheaply testable without fs mocking. The bug it fixes, however, lives purely
* in the candidate ORDER, which cogBinaryCandidates() exposes as a pure,
* arch-injectable function — that is what we pin here.
*/
import { cogBinaryCandidates } from "../src/config.js";
describe("cogBinaryCandidates()", () => {
it("probes -arm before -x86_64 on arm64 hosts", () => {
const c = cogBinaryCandidates("cog-person-count", "arm64");
const arm = c.findIndex((p) => p.endsWith("cog-person-count-arm"));
const x86 = c.findIndex((p) => p.endsWith("cog-person-count-x86_64"));
expect(arm).toBeGreaterThanOrEqual(0);
expect(x86).toBeGreaterThanOrEqual(0);
expect(arm).toBeLessThan(x86);
});
it("probes -x86_64 before -arm on x64 hosts", () => {
const c = cogBinaryCandidates("cog-person-count", "x64");
const arm = c.findIndex((p) => p.endsWith("cog-person-count-arm"));
const x86 = c.findIndex((p) => p.endsWith("cog-person-count-x86_64"));
expect(x86).toBeLessThan(arm);
});
it("defaults an unknown arch to the x86_64-first order", () => {
const c = cogBinaryCandidates("cog-pose-estimation", "riscv64");
const arm = c.findIndex((p) => p.endsWith("cog-pose-estimation-arm"));
const x86 = c.findIndex((p) => p.endsWith("cog-pose-estimation-x86_64"));
expect(x86).toBeLessThan(arm);
});
it("keeps the /usr/local/bin and bare-name PATH fallbacks last", () => {
const c = cogBinaryCandidates("cog-person-count", "arm64");
// The two arch builds come first; the /usr/local/bin fallback follows them.
expect(c[c.length - 1]).toBe("/usr/local/bin/cog-person-count");
expect(c).toHaveLength(3);
});
it("derives the id by stripping the cog- prefix once", () => {
const c = cogBinaryCandidates("cog-person-count", "x64");
expect(c[0]).toBe(
"/var/lib/cognitum/apps/person-count/cog-person-count-x86_64"
);
});
});
@@ -110,6 +110,19 @@ describe("isOriginAllowed()", () => {
expect(isOriginAllowed("https://evil.example.com:443", [])).toBe(false);
});
// ADR-264 F7 hardening: an EXPLICIT allowlist means exact matching only. The
// any-port-localhost convenience applies solely to the empty-allowlist case,
// so an operator who pins an allowlist actually gets it.
it("with an explicit allowlist, rejects a localhost origin on an unlisted port", () => {
expect(isOriginAllowed("http://localhost:5173", allow)).toBe(false);
expect(isOriginAllowed("http://127.0.0.1:8080", allow)).toBe(false);
});
it("with an explicit allowlist, still accepts an exactly-listed localhost origin", () => {
expect(isOriginAllowed("http://localhost", allow)).toBe(true);
expect(isOriginAllowed("http://127.0.0.1", allow)).toBe(true);
});
it("is case-sensitive for non-local allowlist entries per RFC 6454", () => {
expect(isOriginAllowed("HTTPS://Partner.Example.com", ["https://partner.example.com"])).toBe(false);
});
@@ -229,3 +242,68 @@ describe("HTTP transport session + body-cap hardening (ADR-264 F7)", () => {
expect(r.status).toBe(200);
});
});
// ── 8. ADR-264 F7: session-map bounds (cap + idle TTL sweep) ───────────────
describe("HTTP transport session bounds (ADR-264 F7)", () => {
const initBody = (id: number): string =>
JSON.stringify({
jsonrpc: "2.0",
id,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test-client", version: "0.0.0" },
},
});
// Build directly (not via startServer) so we can inspect the sessions map.
async function startWithApp(
opts: Parameters<typeof buildHttpApp>[1],
basePort: number
): Promise<{
port: number;
sessions: ReturnType<typeof buildHttpApp>["sessions"];
close: () => Promise<void>;
}> {
const { httpServer, sessions } = buildHttpApp(() => makeMockMcpServer(), opts);
const port = basePort + Math.floor(Math.random() * 100);
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(port, "127.0.0.1", () => resolve());
});
const close = () =>
new Promise<void>((res, rej) => httpServer.close((e) => (e ? rej(e) : res())));
return { port, sessions, close };
}
const ACCEPT = { Accept: "application/json, text/event-stream" };
it("never exceeds maxSessions — evicts the oldest-idle session at capacity", async () => {
const srv = await startWithApp({ allowedOrigins: ["*"], maxSessions: 2 }, 49800);
try {
for (let i = 0; i < 5; i++) {
await post(srv.port, "/mcp", ACCEPT, initBody(i));
}
expect(srv.sessions.size).toBeLessThanOrEqual(2);
} finally {
await srv.close();
}
});
it("sweeps sessions idle beyond sessionIdleMs", async () => {
const srv = await startWithApp(
{ allowedOrigins: ["*"], sessionIdleMs: 20, sweepIntervalMs: 10 },
49900
);
try {
await post(srv.port, "/mcp", ACCEPT, initBody(1));
expect(srv.sessions.size).toBe(1);
await new Promise((r) => setTimeout(r, 150));
expect(srv.sessions.size).toBe(0);
} finally {
await srv.close();
}
});
});
@@ -0,0 +1,96 @@
/**
* ADR-264 O6 — post-restart job reconciliation.
*
* When the MCP server restarts mid-run, the persisted job record stays frozen
* at 'running' (the child.on('close') that flips it lived in the dead process).
* ruview_job_status must reconcile such a record against the recorded pid and
* the log's "# exit code: N" marker.
*
* We fabricate a persisted record pointing at a KNOWN-DEAD pid (a synchronous
* child that has already exited) and assert the reconciled status.
*/
import { mkdtempSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { jobStatus } from "../src/tools/train-count.js";
import type { RuviewConfig } from "../src/types.js";
/** A pid that has certainly exited: spawnSync waits for the child to finish. */
function deadPid(): number {
const r = spawnSync(process.execPath, ["-e", ""]);
if (typeof r.pid !== "number") throw new Error("could not spawn probe child");
return r.pid;
}
function makeConfig(jobsDir: string): RuviewConfig {
return {
sensingServerUrl: "http://127.0.0.1:19999",
apiToken: undefined,
poseCogBinary: "nonexistent",
countCogBinary: "nonexistent",
jobsDir,
};
}
/** Write a fake persisted 'running' record + its log, return {jobId, config}. */
function seedRunningJob(logBody: string): { jobId: string; config: RuviewConfig } {
const jobsDir = mkdtempSync(path.join(os.tmpdir(), "rvagent-jobs-"));
const jobId = randomUUID();
const logPath = path.join(jobsDir, `${jobId}.log`);
writeFileSync(logPath, logBody);
const record = {
job_id: jobId,
status: "running",
log_path: logPath,
queued_at: Date.now() / 1000,
epochs_total: 5,
pid: deadPid(),
};
writeFileSync(
path.join(jobsDir, `${jobId}.json`),
JSON.stringify(record, null, 2)
);
return { jobId, config: makeConfig(jobsDir) };
}
describe("ruview_job_status reconciliation (ADR-264 O6)", () => {
it("reconciles a dead 'running' job with exit 0 to 'done'", async () => {
const { jobId, config } = seedRunningJob(
"# training...\nepoch 5/5\n# exit code: 0\n"
);
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
expect(out["ok"]).toBe(true);
const res = out["result"] as Record<string, unknown>;
expect(res["status"]).toBe("done");
});
it("reconciles a dead 'running' job with non-zero exit to 'failed'", async () => {
const { jobId, config } = seedRunningJob(
"# training...\npanic: cuda oom\n# exit code: 101\n"
);
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
const res = out["result"] as Record<string, unknown>;
expect(res["status"]).toBe("failed");
});
it("marks a dead 'running' job with no exit marker as 'unknown' with a reason", async () => {
const { jobId, config } = seedRunningJob("# training...\nepoch 2/5\n");
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
const res = out["result"] as Record<string, unknown>;
expect(res["status"]).toBe("unknown");
expect(typeof res["reason"]).toBe("string");
expect(res["reason"]).toMatch(/restarted/i);
});
it("treats a signal-killed marker (null) as 'failed'", async () => {
const { jobId, config } = seedRunningJob(
"# training...\n# exit code: null\n"
);
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
const res = out["result"] as Record<string, unknown>;
expect(res["status"]).toBe("failed");
});
});