Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.0.16-beta.0 — 2026-07-31

### Fixes
- Capture Pi's tool events and Hermes's working directory, both of which the audit adapters were discarding. Pi's parser handled only `text` and `thinking` content blocks, so `toolCall` blocks fell through to the generic "system" branch and the separate `role: "toolResult"` records attached to nothing — Pi contributed zero tool events. The file's own header recorded this as "tool-call blocks are not yet observed", and kept an unused `formatTimestamp` import alive with a `void` for "once Pi emits it", so the gap was known but its premise was wrong rather than stale: verified against pi 0.73.1 and 0.83.0, an assistant turn carries `{type:"toolCall", id, name, arguments}` and each result arrives as its own record with a third role (`toolCallId`, `toolName`, `content[]`, `isError`). Results now pair to their call by id rather than position — Pi emits them in call order today, but pairing by order would break silently the first time it does not — and duration is derived from the call/result gap since Pi records none, matching the OpenClaw parser. Separately, the Hermes adapter returned nothing at all for `audit --project <cwd>`, on the premise that gateway sessions have no working directory; verified against hermes-agent 0.19.0, `sessions` carries real `cwd`, `git_branch` and `git_repo_root` columns and every `source='cli'` session populates them, so a repo the user had driven Hermes in silently reported zero Hermes findings. Sessions with a cwd now filter and group by working directory like Claude/Goose/Devin, while genuinely cwd-less Slack/Telegram sessions keep their `(profile, source)` bucket and stay excluded from cwd filters. (#639)
- Harden the release workflow against shell injection from ref names and generated outputs, align every Bun cache key with the tracked `bun.lock`, and discard the temporary publish-version edit before switching to `main` for the development-version bump. (#634)
- Ship the binaries the release already builds, and stop a branch dispatch from rewriting main's version. The daemon split added every packaging input — platform manifests, pinned optional dependencies, a 4-way cross-compile matrix — but never touched `publish.yml`, so each release built four binaries as Actions artifacts and discarded them with the runner; CI stayed green because nothing checks that what gets built also gets shipped. `publish.yml` is now four jobs — preflight (version/dist-tag resolution, an npm credential check that fails in seconds rather than after a 20-minute matrix, and daemon detection), a call into `build-daemon.yml` as a reusable workflow, an asset job that assembles `SHA256SUMS` and attaches it plus the four binaries to the GitHub Release, and the npm publish — in that order, because the installed CLI downloads its daemon from that release tag and publishing the package first ships a version whose binary does not exist yet. A failed cross-compile now blocks the publish explicitly: a failed dependency leaves its dependents `skipped`, which the old-style guard would have read as "nothing to do". The version bump checks main out and pushes to it, so it runs only for a release or a dispatch from main, and `latest` is refused from a non-main dispatch (`auto` resolves to `next` there) so a branch build cannot move a dist-tag that a later release from main would move backwards. Adds a `dry_run` input that builds, checksums and validates the publish while writing nothing, and fixes the bun cache key, which hashed a `bun.lockb` this repo does not track. All of it is gated on the ref carrying a Rust workspace, so on main this changes nothing until the daemon lands. (#634)

Expand Down
136 changes: 136 additions & 0 deletions __tests__/audit/hermes-adapter-cwd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// @vitest-environment node
//
// Hermes sessions carry a real working directory, and the audit adapter used to
// throw them all away. `listHermesTranscriptMetadata` opened with
//
// if (opts.projects && opts.projects.length > 0) return [];
//
// on the premise that "gateway sessions have no cwd" — so `failproofai audit
// --project <repo>` silently reported zero Hermes findings for a repo the user
// had actually driven Hermes in. Nothing failed; Hermes just was not there.
//
// Verified against hermes-agent 0.19.0: `sessions` has real `cwd`, `git_branch`
// and `git_repo_root` columns, and every `source='cli'` session populated them.
// Slack/Telegram gateway sessions genuinely have none, so both shapes are built
// here and each is asserted separately.
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import initSqlJs from "sql.js/dist/sql-asm.js";

let root: string;
const prevHome = process.env.HERMES_HOME;
const prevDbPath = process.env.HERMES_DB_PATH;

const CLI_ID = "20260803_080402_a54231"; // real hermes id format: not a UUID
const CLI_ID_2 = "20260803_080544_ae362c";
const GATEWAY_ID = "20260803_081000_bb1122";
const REPO = "/home/u/work/repo";
const OTHER_REPO = "/home/u/work/other";

async function writeDb(path: string): Promise<void> {
const SQL = await initSqlJs();
const db = new SQL.Database();
db.run(
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, cwd TEXT, title TEXT, " +
"user_id TEXT, chat_id TEXT, chat_type TEXT, started_at REAL, ended_at REAL, message_count INTEGER);",
);
db.run(
"CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, " +
"tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, timestamp REAL);",
);

// Two CLI sessions in different repos, and one gateway session with no cwd —
// gateway columns (chat_id/chat_type) are NULL on CLI rows, as observed live.
const rows: Array<[string, string, string | null, string, string | null, string | null, number]> = [
[CLI_ID, "cli", REPO, "cli session", null, null, 1_785_744_000],
[CLI_ID_2, "cli", OTHER_REPO, "other repo session", null, null, 1_785_744_100],
[GATEWAY_ID, "slack", null, "gateway session", "C1", "dm", 1_785_744_200],
];
for (const [id, source, cwd, title, chatId, chatType, ts] of rows) {
db.run("INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?,?,?)", [
id, source, cwd, title, "U1", chatId, chatType, ts, ts + 10, 1,
]);
db.run("INSERT INTO messages VALUES (?,?,?,?,?,?,?,?)", [
null, id, "user", `hello from ${title}`, null, null, null, ts + 1,
]);
}

mkdirSync(join(path, ".."), { recursive: true });
writeFileSync(path, Buffer.from(db.export()));
db.close();
}

beforeAll(async () => {
root = mkdtempSync(join(tmpdir(), "hermes-cwd-"));
await writeDb(join(root, "state.db"));
delete process.env.HERMES_DB_PATH;
process.env.HERMES_HOME = root;
});

afterAll(() => {
if (prevHome === undefined) delete process.env.HERMES_HOME;
else process.env.HERMES_HOME = prevHome;
if (prevDbPath === undefined) delete process.env.HERMES_DB_PATH;
else process.env.HERMES_DB_PATH = prevDbPath;
rmSync(root, { recursive: true, force: true });
});

describe("hermes audit adapter — cwd-scoped listing", () => {
it("returns a session whose cwd matches the project filter", async () => {
const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes");
const out = await listHermesTranscriptMetadata({ projects: [REPO] });
// Was [] unconditionally — this is the whole bug.
expect(out.map((m) => m.sessionId)).toEqual([CLI_ID]);
});

it("excludes sessions from other repos", async () => {
const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes");
const out = await listHermesTranscriptMetadata({ projects: [OTHER_REPO] });
expect(out.map((m) => m.sessionId)).toEqual([CLI_ID_2]);
});

it("excludes cwd-less gateway sessions from any cwd filter", async () => {
const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes");
const out = await listHermesTranscriptMetadata({ projects: [REPO, OTHER_REPO] });
expect(out.map((m) => m.sessionId).sort()).toEqual([CLI_ID, CLI_ID_2].sort());
expect(out.some((m) => m.sessionId === GATEWAY_ID)).toBe(false);
});

it("returns nothing for a project no Hermes session ran in", async () => {
const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes");
const out = await listHermesTranscriptMetadata({ projects: ["/nowhere"] });
expect(out).toEqual([]);
});

it("still returns every session when no project filter is given", async () => {
const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes");
const out = await listHermesTranscriptMetadata();
expect(out.map((m) => m.sessionId).sort()).toEqual([CLI_ID, CLI_ID_2, GATEWAY_ID].sort());
});
});

describe("hermes audit adapter — project grouping", () => {
it("groups a cwd-bearing session by its working directory", async () => {
const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes");
const { encodeFolderName } = await import("@/lib/paths");
const out = await listHermesTranscriptMetadata();
const cli = out.find((m) => m.sessionId === CLI_ID)!;
expect(cli.projectName).toBe(encodeFolderName(REPO));
});

it("keeps the (profile, source) bucket for a gateway session", async () => {
const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes");
const out = await listHermesTranscriptMetadata();
const gw = out.find((m) => m.sessionId === GATEWAY_ID)!;
// Unchanged behaviour for the sessions that really are cwd-less.
expect(gw.projectName).toBe("hermes:default:slack");
});

it("keeps the hermes:// transcript path form for every session", async () => {
const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes");
const out = await listHermesTranscriptMetadata();
for (const m of out) expect(m.transcriptPath).toBe(`hermes://${m.sessionId}`);
});
});
139 changes: 139 additions & 0 deletions __tests__/lib/pi-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { AssistantEntry, ContentBlock, ToolUseBlock } from "@/lib/log-entries";

const SAFE_UUID = "00000000-0000-4000-8000-000000000001";
const SECOND_UUID = "00000000-0000-4000-8000-000000000002";
Expand Down Expand Up @@ -205,4 +206,142 @@ describe("lib/pi-sessions", () => {
expect(mod.readPiTranscriptSync("../etc/passwd")).toBeNull();
});
});

// Record shapes below are verbatim from a live pi capture (0.73.1 and
// 0.83.0, driven against a real provider). Before this, `toolCall` blocks
// fell through to the generic "system" branch, so every tool event pi
// emitted was dropped — the parser looked correct because nothing asserted
// on a tool-using transcript.
describe("tool calls", () => {
const CALL_A = "toolu_bdrk_01AWG5F1T6gf9BGKRb2h21bP";
const CALL_B = "toolu_bdrk_01QoT5TiSRRs8mfJzcMSMPAe";

function assistantContent(entries: Array<{ type: string }>): ContentBlock[] {
const assistant = entries.find((e) => e.type === "assistant") as AssistantEntry | undefined;
expect(assistant).toBeDefined();
return assistant!.message.content;
}


function toolCallRecord(ts: string): string {
return JSON.stringify({
type: "message",
id: "81470a2e",
timestamp: ts,
message: {
role: "assistant",
content: [
{ type: "toolCall", id: CALL_A, name: "bash", arguments: { command: "ls -la /tmp/probe-pi" } },
{ type: "toolCall", id: CALL_B, name: "read", arguments: { path: "/tmp/probe-pi/README.md" } },
],
stopReason: "toolUse",
},
});
}

function toolResultRecord(callId: string, toolName: string, text: string, ts: string): string {
return JSON.stringify({
type: "message",
id: "fe29ac29",
parentId: "81470a2e",
timestamp: ts,
message: {
role: "toolResult",
toolCallId: callId,
toolName,
content: [{ type: "text", text }],
isError: false,
timestamp: Date.parse(ts),
},
});
}

it("parses toolCall blocks into tool_use blocks with their arguments", async () => {
writeSession(SAFE_UUID, "/home/u/repo", [toolCallRecord("2026-05-01T20:36:30.000Z")]);
const result = await mod.getPiSessionLog(SAFE_UUID);
const tools = assistantContent(result!.entries).filter(
(b): b is ToolUseBlock => b.type === "tool_use",
);
expect(tools).toHaveLength(2);
expect(tools[0]).toMatchObject({ id: CALL_A, name: "bash", input: { command: "ls -la /tmp/probe-pi" } });
expect(tools[1]).toMatchObject({ id: CALL_B, name: "read", input: { path: "/tmp/probe-pi/README.md" } });
});

it("attaches a toolResult to its call by id, not by position", async () => {
// Results deliberately out of call order: pairing by position would put
// the `read` output on the `bash` call and neither would be detectably
// wrong from the shape alone.
writeSession(SAFE_UUID, "/home/u/repo", [
toolCallRecord("2026-05-01T20:36:30.000Z"),
toolResultRecord(CALL_B, "read", "# Probe Pi", "2026-05-01T20:36:31.000Z"),
toolResultRecord(CALL_A, "bash", "total 144", "2026-05-01T20:36:32.000Z"),
]);
const result = await mod.getPiSessionLog(SAFE_UUID);
const tools = assistantContent(result!.entries).filter(
(b): b is ToolUseBlock => b.type === "tool_use",
);

expect(tools.find((t) => t.id === CALL_A)!.result!.content).toBe("total 144");
expect(tools.find((t) => t.id === CALL_B)!.result!.content).toBe("# Probe Pi");
});

it("derives a duration from the call/result gap, since pi records none", async () => {
writeSession(SAFE_UUID, "/home/u/repo", [
toolCallRecord("2026-05-01T20:36:30.000Z"),
toolResultRecord(CALL_A, "bash", "total 144", "2026-05-01T20:36:32.500Z"),
]);
const result = await mod.getPiSessionLog(SAFE_UUID);
const tool = assistantContent(result!.entries).find(
(b): b is ToolUseBlock => b.type === "tool_use" && b.id === CALL_A,
);
expect(tool!.result!.durationMs).toBe(2500);
});

it("keeps an orphan toolResult as a system entry rather than dropping it", async () => {
// A result whose call is not in this file (truncated, or a resumed
// session split across files) must still be preserved.
writeSession(SAFE_UUID, "/home/u/repo", [
toolResultRecord("toolu_never_seen", "bash", "orphaned", "2026-05-01T20:36:31.000Z"),
]);
const result = await mod.getPiSessionLog(SAFE_UUID);
const system = result!.entries.filter((e) => e.type === "system");
expect(system).toHaveLength(1);
});

it("handles 0.83.0's mixed text+toolCall assistant content", async () => {
// 0.73.1 emitted ["toolCall","toolCall"]; 0.83.0 adds leading prose.
// Assistant content must not be assumed homogeneous.
const mixed = JSON.stringify({
type: "message",
id: "abc",
timestamp: "2026-05-01T20:36:30.000Z",
message: {
role: "assistant",
content: [
{ type: "text", text: "Let me look at that." },
{ type: "toolCall", id: CALL_A, name: "bash", arguments: { command: "ls" } },
],
stopReason: "toolUse",
},
});
writeSession(SAFE_UUID, "/home/u/repo", [mixed]);
const result = await mod.getPiSessionLog(SAFE_UUID);
const content = assistantContent(result!.entries);
expect(content.map((b) => b.type)).toEqual(["text", "tool_use"]);
});

it("gives a toolCall with no id a synthetic one so it still renders", async () => {
const noId = JSON.stringify({
type: "message",
id: "abc",
timestamp: "2026-05-01T20:36:30.000Z",
message: { role: "assistant", content: [{ type: "toolCall", name: "bash", arguments: { command: "ls" } }] },
});
writeSession(SAFE_UUID, "/home/u/repo", [noId]);
const result = await mod.getPiSessionLog(SAFE_UUID);
const content = assistantContent(result!.entries);
expect(content[0].type).toBe("tool_use");
expect((content[0] as ToolUseBlock).id).toBeTruthy();
});
});
});
Loading