Skip to content
Open
Show file tree
Hide file tree
Changes from all 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