4aae61297f
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
236 lines
9.2 KiB
JavaScript
236 lines
9.2 KiB
JavaScript
/**
|
|
* @file Tests the push path and the privacy settings end to end:
|
|
* `POST /api/hooks/ingest-batch` (off until a token exists, bearer token
|
|
* checked, sessions tagged `push:<source>`, a session another source owns
|
|
* refused, the sender's transcript paths dropped, batch limits) with the token
|
|
* routes `/api/settings/push-token` (shown once, stored only as a hash,
|
|
* rotate/revoke), and `/api/settings/privacy` (ignored folders dropping hooks,
|
|
* opt-in secret redaction of stored payloads and summaries, validation, the
|
|
* same-origin guard). DB, homes and the settings file live in a temp dir.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const os = require("node:os");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-push-ingest-"));
|
|
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
|
process.env.CODEX_HOME = path.join(ROOT, "codex");
|
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
|
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
|
|
delete process.env.AGENTHUB_PUSH_TOKEN_SHA256;
|
|
delete process.env.AGENTHUB_REDACT_SECRETS;
|
|
delete process.env.AGENTHUB_IGNORE_CWD;
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const http = require("node:http");
|
|
const express = require("express");
|
|
|
|
const { db, stmts } = require("../db");
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
before(async () => {
|
|
const app = express();
|
|
app.use(express.json({ limit: "1mb" }));
|
|
app.use("/api/hooks", require("../routes/hooks"));
|
|
app.use("/api/settings", require("../routes/settings"));
|
|
server = http.createServer(app);
|
|
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
|
|
after(() => {
|
|
server?.close();
|
|
db.close();
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
async function call(method, p, body, headers = {}) {
|
|
const res = await fetch(BASE + p, {
|
|
method,
|
|
headers: { "Content-Type": "application/json", ...headers },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
return { status: res.status, body: await res.json() };
|
|
}
|
|
const bearer = (t) => ({ Authorization: `Bearer ${t}` });
|
|
const batch = (token, body) => call("POST", "/api/hooks/ingest-batch", body, bearer(token));
|
|
const hook = (session_id, hook_type = "SessionStart", extra = {}) => ({
|
|
hook_type,
|
|
data: { session_id, cwd: "/remote/proj", ...extra },
|
|
});
|
|
|
|
describe("push ingest", () => {
|
|
let token;
|
|
|
|
it("is off (503) until a token exists", async () => {
|
|
const r = await batch("anything", { source: "laptop", events: [hook("p0")] });
|
|
assert.equal(r.status, 503);
|
|
assert.equal(r.body.error.code, "EPUSHDISABLED");
|
|
assert.deepEqual((await call("GET", "/api/settings/push-token")).body, {
|
|
enabled: false,
|
|
env_owned: false,
|
|
});
|
|
});
|
|
|
|
it("shows a new token once and stores only its hash", async () => {
|
|
const r = await call("POST", "/api/settings/push-token");
|
|
assert.equal(r.status, 200);
|
|
token = r.body.token;
|
|
assert.match(token, /^ahp_[A-Za-z0-9_-]{40,}$/);
|
|
const status = await call("GET", "/api/settings/push-token");
|
|
assert.deepEqual(status.body, { enabled: true, env_owned: false });
|
|
const file = fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8");
|
|
assert.ok(!file.includes(token));
|
|
assert.match(file, /^AGENTHUB_PUSH_TOKEN_SHA256=[0-9a-f]{64}$/m);
|
|
});
|
|
|
|
it("refuses a missing or wrong token", async () => {
|
|
assert.equal(
|
|
(await batch("ahp_wrong", { source: "laptop", events: [hook("p0")] })).status,
|
|
401
|
|
);
|
|
const r = await call("POST", "/api/hooks/ingest-batch", { source: "laptop", events: [] });
|
|
assert.equal(r.status, 401);
|
|
});
|
|
|
|
it("stores the batch tagged with its source and drops the sender's transcript paths", async () => {
|
|
const r = await batch(token, {
|
|
source: "laptop",
|
|
events: [
|
|
hook("p1", "SessionStart", { transcript_path: "/etc/passwd" }),
|
|
hook("p1", "UserPromptSubmit", { prompt: "hello there" }),
|
|
{ hook_type: "bad type!", data: {} },
|
|
],
|
|
});
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.source, "push:laptop");
|
|
assert.equal(r.body.stored, 2);
|
|
assert.deepEqual(r.body.results[2], { ok: false, error: "INVALID_INPUT" });
|
|
const s = stmts.getSession.get("p1");
|
|
assert.equal(s.source, "push:laptop");
|
|
assert.ok(!s.transcript_path);
|
|
const data = db.prepare("SELECT data FROM events WHERE session_id = 'p1'").all();
|
|
assert.ok(data.every((e) => !e.data.includes("/etc/passwd")));
|
|
});
|
|
|
|
it("never touches a session another source owns", async () => {
|
|
await call("POST", "/api/hooks/event", hook("local1"));
|
|
assert.equal(stmts.getSession.get("local1").source, "local");
|
|
const before = db
|
|
.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'local1'")
|
|
.get().n;
|
|
const r = await batch(token, { source: "laptop", events: [hook("local1", "Stop")] });
|
|
assert.deepEqual(r.body.results[0], { ok: false, error: "EFOREIGN" });
|
|
const after = db
|
|
.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'local1'")
|
|
.get().n;
|
|
assert.equal(after, before);
|
|
// Nor one a different pushing machine created.
|
|
const other = await batch(token, { source: "desk", events: [hook("p1", "Stop")] });
|
|
assert.deepEqual(other.body.results[0], { ok: false, error: "EFOREIGN" });
|
|
});
|
|
|
|
it("checks the source name and the batch size", async () => {
|
|
assert.equal((await batch(token, { source: "../x", events: [hook("p2")] })).status, 400);
|
|
assert.equal((await batch(token, { source: "laptop", events: [] })).status, 400);
|
|
const many = Array.from({ length: 501 }, (_, i) => hook(`m${i}`));
|
|
assert.equal((await batch(token, { source: "laptop", events: many })).status, 400);
|
|
});
|
|
|
|
it("stops the old token on rotate, and everything on revoke", async () => {
|
|
const r = await call("POST", "/api/settings/push-token");
|
|
assert.notEqual(r.body.token, token);
|
|
assert.equal((await batch(token, { source: "laptop", events: [hook("p3")] })).status, 401);
|
|
assert.equal(
|
|
(await batch(r.body.token, { source: "laptop", events: [hook("p3")] })).status,
|
|
200
|
|
);
|
|
const off = await call("DELETE", "/api/settings/push-token");
|
|
assert.deepEqual(off.body, { ok: true, enabled: false, env_owned: false });
|
|
assert.equal(
|
|
(await batch(r.body.token, { source: "laptop", events: [hook("p4")] })).status,
|
|
503
|
|
);
|
|
});
|
|
|
|
it("refuses a token write from another site", async () => {
|
|
const r = await call("POST", "/api/settings/push-token", undefined, {
|
|
Origin: "https://evil.example",
|
|
});
|
|
assert.equal(r.status, 403);
|
|
});
|
|
});
|
|
|
|
describe("privacy settings", () => {
|
|
it("starts off", async () => {
|
|
const r = await call("GET", "/api/settings/privacy");
|
|
assert.deepEqual(r.body, {
|
|
redact_secrets: false,
|
|
ignore_cwd: [],
|
|
env_owned: { redact_secrets: false, ignore_cwd: false },
|
|
});
|
|
});
|
|
|
|
it("drops every hook from an ignored folder, and keeps its siblings", async () => {
|
|
const r = await call("PUT", "/api/settings/privacy", { ignore_cwd: ["/private/work/"] });
|
|
assert.equal(r.status, 200);
|
|
assert.deepEqual(r.body.ignore_cwd, ["/private/work"]);
|
|
const dropped = await call("POST", "/api/hooks/event", {
|
|
hook_type: "SessionStart",
|
|
data: { session_id: "ig1", cwd: "/private/work/app" },
|
|
});
|
|
assert.deepEqual(dropped.body, { ok: true, ignored: true });
|
|
assert.equal(stmts.getSession.get("ig1"), undefined);
|
|
await call("POST", "/api/hooks/event", {
|
|
hook_type: "SessionStart",
|
|
data: { session_id: "ig2", cwd: "/private/work-other" },
|
|
});
|
|
assert.ok(stmts.getSession.get("ig2"));
|
|
});
|
|
|
|
it("redacts stored payloads and summaries once turned on", async () => {
|
|
const r = await call("PUT", "/api/settings/privacy", { redact_secrets: true });
|
|
assert.equal(r.body.redact_secrets, true);
|
|
await call("POST", "/api/hooks/event", {
|
|
hook_type: "PreToolUse",
|
|
data: {
|
|
session_id: "rd1",
|
|
cwd: "/w",
|
|
tool_name: "Bash",
|
|
tool_input: { command: "curl -H 'Authorization: Bearer abcdefgh12345678' https://x" },
|
|
},
|
|
});
|
|
const rows = db
|
|
.prepare(
|
|
"SELECT summary, data FROM events WHERE session_id = 'rd1' AND event_type = 'PreToolUse'"
|
|
)
|
|
.all();
|
|
assert.equal(rows.length, 1);
|
|
assert.ok(!rows[0].data.includes("abcdefgh12345678"));
|
|
assert.ok(!String(rows[0].summary).includes("abcdefgh12345678"));
|
|
assert.ok(rows[0].data.includes("[redacted]"));
|
|
await call("PUT", "/api/settings/privacy", { redact_secrets: false, ignore_cwd: [] });
|
|
});
|
|
|
|
it("refuses a bad list, a bad flag and another site", async () => {
|
|
assert.equal((await call("PUT", "/api/settings/privacy", { ignore_cwd: ["rel"] })).status, 400);
|
|
assert.equal((await call("PUT", "/api/settings/privacy", { ignore_cwd: ["/"] })).status, 400);
|
|
assert.equal(
|
|
(await call("PUT", "/api/settings/privacy", { redact_secrets: "yes" })).status,
|
|
400
|
|
);
|
|
const r = await call(
|
|
"PUT",
|
|
"/api/settings/privacy",
|
|
{ redact_secrets: true },
|
|
{ Origin: "https://evil.example" }
|
|
);
|
|
assert.equal(r.status, 403);
|
|
});
|
|
});
|