@botbuddy/cli 1.34.0 → 1.34.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands.mjs +63 -5
- package/src/usage/claude.mjs +157 -0
- package/src/usage/codex.mjs +236 -0
- package/src/usage/cursor.mjs +151 -0
- package/src/usage/envelope.mjs +313 -0
- package/src/usage/launchd.mjs +170 -0
- package/src/usage/sync.mjs +725 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// BOT-803 — per-file byte-offset cursors for usage sync.
|
|
2
|
+
//
|
|
3
|
+
// `~/.botbuddy/usage-cursors.json` (0600) maps each transcript's source HMAC
|
|
4
|
+
// (never its path) to the byte offset just past the last COMPLETE line that was
|
|
5
|
+
// successfully delivered, plus the adapter's derived per-file context. A live
|
|
6
|
+
// writer's half-written last line is never consumed, so the next run resumes on
|
|
7
|
+
// it. Writes are tmp-file + rename and merge with whatever is on disk at write
|
|
8
|
+
// time — no lock, so a pre-push run can never wait on the daily sweep (BOT-1023
|
|
9
|
+
// AC4). A lost race only means a later run re-reads a few lines; the server's
|
|
10
|
+
// idempotency key makes that a no-op.
|
|
11
|
+
import { open, readFile, rename, mkdir, writeFile, unlink } from "node:fs/promises";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
export const CURSOR_FILE = "usage-cursors.json";
|
|
17
|
+
export const MAX_RUNS_KEPT = 20;
|
|
18
|
+
const CHUNK = 1 << 20;
|
|
19
|
+
const NEWLINE = 0x0a;
|
|
20
|
+
|
|
21
|
+
export function cursorPath(home = homedir()) {
|
|
22
|
+
return join(home, ".botbuddy", CURSOR_FILE);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const emptyState = () => ({ schema_version: 1, files: {}, runs: [] });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Load the cursor state. A MISSING file is an empty state; a corrupt or
|
|
29
|
+
* unknown-schema file fails closed with `cursor_state_invalid` (Codex #982 R7):
|
|
30
|
+
* reading it as empty would drop the offsets that keep partly read files in
|
|
31
|
+
* scope and then overwrite them. The file is left untouched for the user —
|
|
32
|
+
* delete it to start over (every transcript in --since is re-read once and the
|
|
33
|
+
* server dedupes).
|
|
34
|
+
*/
|
|
35
|
+
export async function loadCursorState(path) {
|
|
36
|
+
let text;
|
|
37
|
+
try {
|
|
38
|
+
text = await readFile(path, "utf8");
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error?.code === "ENOENT") return emptyState();
|
|
41
|
+
throw Object.assign(new Error("usage cursor state is unreadable"), { code: "cursor_state_invalid" });
|
|
42
|
+
}
|
|
43
|
+
let parsed;
|
|
44
|
+
try { parsed = JSON.parse(text); } catch { parsed = null; }
|
|
45
|
+
if (!parsed || typeof parsed !== "object" || parsed.schema_version !== 1
|
|
46
|
+
|| (parsed.files !== undefined && (typeof parsed.files !== "object" || parsed.files === null || Array.isArray(parsed.files)))) {
|
|
47
|
+
throw Object.assign(new Error("usage cursor state is corrupt or from another version"), { code: "cursor_state_invalid" });
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
schema_version: 1,
|
|
51
|
+
files: parsed.files ?? {},
|
|
52
|
+
runs: Array.isArray(parsed.runs) ? parsed.runs : [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Merge `updates` (source_hmac → entry) and `runs` into the on-disk state and
|
|
58
|
+
* write it atomically. For a file both sides know, the entry with the larger
|
|
59
|
+
* offset wins — two overlapping triggers can only move a cursor forward.
|
|
60
|
+
*/
|
|
61
|
+
export async function saveCursorState(path, { files = {}, runs = [] } = {}) {
|
|
62
|
+
const current = await loadCursorState(path);
|
|
63
|
+
for (const [key, entry] of Object.entries(files)) {
|
|
64
|
+
const existing = current.files[key];
|
|
65
|
+
if (!existing || !(existing.offset > entry.offset) || entry.reset === true) {
|
|
66
|
+
const { reset: _reset, ...clean } = entry;
|
|
67
|
+
current.files[key] = clean;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
current.runs = [...current.runs, ...runs].slice(-MAX_RUNS_KEPT);
|
|
71
|
+
await mkdir(join(path, ".."), { recursive: true, mode: 0o700 });
|
|
72
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
73
|
+
try {
|
|
74
|
+
await writeFile(tmp, `${JSON.stringify(current)}\n`, { mode: 0o600 });
|
|
75
|
+
await rename(tmp, path);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await unlink(tmp).catch(() => {});
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
return current;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A transcript line longer than this is hostile or corrupt: skipped, never buffered. */
|
|
84
|
+
export const MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
85
|
+
const STOP_CHECK_EVERY = 256;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Stream the complete lines of `path` starting at byte `fromOffset`.
|
|
89
|
+
* `onLine(text, lineStartOffset)` is called per newline-terminated line (a
|
|
90
|
+
* trailing CR is stripped). Returns the offset just past the last line handed
|
|
91
|
+
* out, whether an unterminated tail (a live writer mid-line) was left unread,
|
|
92
|
+
* whether the budget stopped the read, and how many oversized lines were
|
|
93
|
+
* skipped. `shouldStop()` is polled between chunks AND every 256 lines, so one
|
|
94
|
+
* 1 MiB chunk of tiny lines cannot outlive the budget; a line longer than
|
|
95
|
+
* `maxLineBytes` is dropped (counted) instead of growing the buffer unbounded.
|
|
96
|
+
*/
|
|
97
|
+
export async function readCompleteLines(path, fromOffset, onLine, { shouldStop = () => false, maxLineBytes = MAX_LINE_BYTES } = {}) {
|
|
98
|
+
const handle = await open(path, "r");
|
|
99
|
+
try {
|
|
100
|
+
let position = fromOffset;
|
|
101
|
+
let consumed = fromOffset;
|
|
102
|
+
let carry = Buffer.alloc(0);
|
|
103
|
+
const buffer = Buffer.alloc(CHUNK);
|
|
104
|
+
let stopped = false;
|
|
105
|
+
let skipping = false;
|
|
106
|
+
let oversized = 0;
|
|
107
|
+
let lines = 0;
|
|
108
|
+
outer: for (;;) {
|
|
109
|
+
if (shouldStop()) { stopped = true; break; }
|
|
110
|
+
const { bytesRead } = await handle.read(buffer, 0, CHUNK, position);
|
|
111
|
+
if (bytesRead === 0) break;
|
|
112
|
+
position += bytesRead;
|
|
113
|
+
let data = carry.length ? Buffer.concat([carry, buffer.subarray(0, bytesRead)]) : Buffer.from(buffer.subarray(0, bytesRead));
|
|
114
|
+
let start = 0;
|
|
115
|
+
if (skipping) {
|
|
116
|
+
const nl = data.indexOf(NEWLINE);
|
|
117
|
+
if (nl === -1) { consumed += data.length; carry = Buffer.alloc(0); continue; }
|
|
118
|
+
start = nl + 1;
|
|
119
|
+
skipping = false;
|
|
120
|
+
}
|
|
121
|
+
for (;;) {
|
|
122
|
+
const nl = data.indexOf(NEWLINE, start);
|
|
123
|
+
if (nl === -1) break;
|
|
124
|
+
let end = nl;
|
|
125
|
+
if (end > start && data[end - 1] === 0x0d) end -= 1;
|
|
126
|
+
if (nl - start > maxLineBytes) oversized += 1;
|
|
127
|
+
else onLine(data.toString("utf8", start, end), consumed + start);
|
|
128
|
+
start = nl + 1;
|
|
129
|
+
lines += 1;
|
|
130
|
+
if (lines % STOP_CHECK_EVERY === 0 && shouldStop()) {
|
|
131
|
+
stopped = true;
|
|
132
|
+
consumed += start;
|
|
133
|
+
carry = Buffer.alloc(0);
|
|
134
|
+
break outer;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
consumed += start;
|
|
138
|
+
carry = data.subarray(start);
|
|
139
|
+
if (carry.length > maxLineBytes) {
|
|
140
|
+
oversized += 1;
|
|
141
|
+
consumed += carry.length;
|
|
142
|
+
carry = Buffer.alloc(0);
|
|
143
|
+
skipping = true;
|
|
144
|
+
}
|
|
145
|
+
data = null;
|
|
146
|
+
}
|
|
147
|
+
return { endOffset: consumed, partialTail: carry.length > 0, stopped, oversized };
|
|
148
|
+
} finally {
|
|
149
|
+
await handle.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// BOT-803 — the usage envelope: an explicit allowlist of derived, non-content
|
|
2
|
+
// fields for one assistant usage record. Nothing from a transcript is ever spread
|
|
3
|
+
// or copied wholesale: every key below is named, and every value is re-derived
|
|
4
|
+
// (ids, timestamps, token integers, booleans, HMACs). Prompt text, tool inputs,
|
|
5
|
+
// slugs, summaries, attribution fields, and paths never reach an envelope.
|
|
6
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
7
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The complete envelope schema, in wire order — identical to PR 1's server
|
|
13
|
+
* allowlist `USAGE_ENVELOPE_FIELDS` (supabase/functions/_shared/usageEnvelope.ts,
|
|
14
|
+
* BOT-1019). finalizeEnvelope emits exactly these keys.
|
|
15
|
+
*/
|
|
16
|
+
export const ENVELOPE_FIELDS = Object.freeze([
|
|
17
|
+
"provider",
|
|
18
|
+
"provider_schema_version",
|
|
19
|
+
"harness_session_id",
|
|
20
|
+
"request_id",
|
|
21
|
+
"message_id",
|
|
22
|
+
"record_uuid",
|
|
23
|
+
"occurred_at",
|
|
24
|
+
"model",
|
|
25
|
+
"input_tokens",
|
|
26
|
+
"output_tokens",
|
|
27
|
+
"cache_creation_input_tokens",
|
|
28
|
+
"cache_read_input_tokens",
|
|
29
|
+
"codex_input_tokens",
|
|
30
|
+
"codex_cached_input_tokens",
|
|
31
|
+
"codex_output_tokens",
|
|
32
|
+
"codex_reasoning_output_tokens",
|
|
33
|
+
"tool_call_count",
|
|
34
|
+
"is_sidechain",
|
|
35
|
+
"usage_final",
|
|
36
|
+
"usage_quality",
|
|
37
|
+
"git_branch",
|
|
38
|
+
"repo",
|
|
39
|
+
"commit_sha",
|
|
40
|
+
"pr_number",
|
|
41
|
+
"ticket_key_inferred",
|
|
42
|
+
"attribution_confidence",
|
|
43
|
+
"environment",
|
|
44
|
+
"cwd_hmac",
|
|
45
|
+
"source_hmac",
|
|
46
|
+
"source_offset",
|
|
47
|
+
"parser_version",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const INTEGER_FIELDS = new Set([
|
|
51
|
+
"input_tokens",
|
|
52
|
+
"output_tokens",
|
|
53
|
+
"cache_creation_input_tokens",
|
|
54
|
+
"cache_read_input_tokens",
|
|
55
|
+
"codex_input_tokens",
|
|
56
|
+
"codex_cached_input_tokens",
|
|
57
|
+
"codex_output_tokens",
|
|
58
|
+
"codex_reasoning_output_tokens",
|
|
59
|
+
"tool_call_count",
|
|
60
|
+
"pr_number",
|
|
61
|
+
"source_offset",
|
|
62
|
+
]);
|
|
63
|
+
const BOOLEAN_FIELDS = new Set(["is_sidechain", "usage_final"]);
|
|
64
|
+
|
|
65
|
+
/** Substrings that must never appear in any envelope string (paths, e-mail-ish identities). Mirrors the server's list. */
|
|
66
|
+
export const FORBIDDEN_SUBSTRINGS = Object.freeze(["/Users/", "/home/", "/root/", "@", "\\"]);
|
|
67
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
68
|
+
|
|
69
|
+
/** A UUID string, or null (the server keys harness sessions and record ids on UUIDs). */
|
|
70
|
+
export const uuid = (value) => (typeof value === "string" && UUID_RE.test(value) ? value : null);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The server derives usage_quality the same way; sending it keeps the local
|
|
74
|
+
* receipt honest: 'measured' only for a final usage object carrying every
|
|
75
|
+
* native class of its provider.
|
|
76
|
+
*/
|
|
77
|
+
export function usageQuality({ usageFinal, classes }) {
|
|
78
|
+
return usageFinal && classes.every((c) => c !== null && c !== undefined) ? "measured" : "partial";
|
|
79
|
+
}
|
|
80
|
+
const TICKET_RE = /\b(BOT|ENT)-\d+\b/i;
|
|
81
|
+
const MAX_STRING = 256;
|
|
82
|
+
|
|
83
|
+
/** A non-empty string, or null. */
|
|
84
|
+
export const str = (value) => (typeof value === "string" && value.length > 0 ? value : null);
|
|
85
|
+
|
|
86
|
+
/** A non-negative integer token count, or null when absent/garbage. */
|
|
87
|
+
// A non-negative SAFE INTEGER as-is (the server's rule); a fractional, negative,
|
|
88
|
+
// huge or non-numeric value is invalid and becomes null — never truncated into a
|
|
89
|
+
// lower count that would still read as "measured" (Codex #982 R7).
|
|
90
|
+
export const tok = (value) => (Number.isSafeInteger(value) && value >= 0 ? value : null);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A descriptive string (branch, repo, model, version) that is dropped — not sent —
|
|
94
|
+
* when it carries a forbidden substring. Identity fields are NOT sanitized here:
|
|
95
|
+
* the privacy self-check is their backstop and aborts the batch instead.
|
|
96
|
+
*/
|
|
97
|
+
export function safeLabel(value) {
|
|
98
|
+
const s = str(value);
|
|
99
|
+
if (!s || s.length > MAX_STRING) return null;
|
|
100
|
+
return FORBIDDEN_SUBSTRINGS.some((bad) => s.includes(bad)) ? null : s;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Mirrors the server's model rule (usageEnvelope.ts MODEL_RE / VERTEX_MODEL_RE,
|
|
104
|
+
// BOT-1019): a plain model id, or a Vertex `<model>@YYYYMMDD` snapshot whose base
|
|
105
|
+
// passes the plain rule. That 8-digit suffix is the ONE '@' allowed anywhere.
|
|
106
|
+
const MODEL_RE = /^[A-Za-z0-9._:\-[\]]{1,120}$/;
|
|
107
|
+
const VERTEX_MODEL_RE = /^([A-Za-z0-9._:\-[\]]{1,110})@[0-9]{8}$/;
|
|
108
|
+
const isVertexModel = (value) => typeof value === "string" && VERTEX_MODEL_RE.test(value);
|
|
109
|
+
|
|
110
|
+
/** A model id the server accepts, or null. */
|
|
111
|
+
export function modelLabel(value) {
|
|
112
|
+
const s = str(value);
|
|
113
|
+
if (!s) return null;
|
|
114
|
+
if (MODEL_RE.test(s)) return safeLabel(s);
|
|
115
|
+
const m = VERTEX_MODEL_RE.exec(s);
|
|
116
|
+
return m && MODEL_RE.test(m[1]) && safeLabel(m[1]) ? s : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function inferTicketKey(gitBranch, cwd) {
|
|
120
|
+
const candidates = [str(gitBranch)];
|
|
121
|
+
const cwdStr = str(cwd);
|
|
122
|
+
if (cwdStr) {
|
|
123
|
+
const segments = cwdStr.split(/[\\/]+/).filter(Boolean);
|
|
124
|
+
candidates.push(segments.length ? segments[segments.length - 1] : null);
|
|
125
|
+
}
|
|
126
|
+
for (const candidate of candidates) {
|
|
127
|
+
if (!candidate) continue;
|
|
128
|
+
const match = candidate.match(TICKET_RE);
|
|
129
|
+
if (match) return match[0].toUpperCase();
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** GitHub `owner/name` from a remote URL; anything else (incl. credentials in the URL) → null. */
|
|
135
|
+
export function normalizeRepo(remoteUrl) {
|
|
136
|
+
const raw = str(remoteUrl);
|
|
137
|
+
if (!raw) return null;
|
|
138
|
+
const match = raw.trim().replace(/\.git$/i, "").match(/github\.com[/:]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/i);
|
|
139
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A 7–40 char lowercase hex commit sha, or null. */
|
|
143
|
+
export function commitSha(value) {
|
|
144
|
+
const s = str(value);
|
|
145
|
+
return s && /^[0-9a-f]{7,40}$/i.test(s) ? s.toLowerCase() : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build an envelope with EXACTLY the allowlisted keys. Unknown input keys are
|
|
150
|
+
* ignored; missing keys become null; integers/booleans are coerced defensively.
|
|
151
|
+
*/
|
|
152
|
+
// The server's field rules (supabase/functions/_shared/usageEnvelope.ts,
|
|
153
|
+
// BOT-1019), mirrored so a value the allowlist would reject never leaves the
|
|
154
|
+
// machine at all (Codex #982 R5): optional fields become null, and a row whose
|
|
155
|
+
// identity fails is refused locally (envelopeRefusal).
|
|
156
|
+
const PROVIDER_ID_RE = /^[A-Za-z0-9_.:-]{1,200}$/;
|
|
157
|
+
const VERSION_RE = /^[A-Za-z0-9._+-]{1,40}$/;
|
|
158
|
+
const BRANCH_RE = /^[A-Za-z0-9._/-]{1,200}$/;
|
|
159
|
+
const REPO_RE = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
|
|
160
|
+
const SHA_RE = /^[0-9a-f]{7,40}$/;
|
|
161
|
+
const TICKET_KEY_RE = /^[A-Z][A-Z0-9]{1,9}-[0-9]{1,7}$/;
|
|
162
|
+
const ENV_RE = /^[a-z0-9_-]{1,32}$/;
|
|
163
|
+
const HMAC_RE = /^[0-9a-f]{64}$/;
|
|
164
|
+
const ISO_TS_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?$/i;
|
|
165
|
+
const ATTRIBUTION = ["deterministic", "inferred", "unattributed"];
|
|
166
|
+
|
|
167
|
+
const matching = (re) => (v) => (typeof v === "string" && re.test(v.trim()) && !FORBIDDEN_SUBSTRINGS.some((bad) => v.includes(bad)) ? v.trim() : null);
|
|
168
|
+
function daysInMonth(year, month) {
|
|
169
|
+
if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
|
|
170
|
+
return [4, 6, 9, 11].includes(month) ? 30 : 31;
|
|
171
|
+
}
|
|
172
|
+
/** A calendar-valid ISO timestamp normalised to UTC (the server's isoTimestamp), else null. */
|
|
173
|
+
export function isoTimestamp(v) {
|
|
174
|
+
if (typeof v !== "string") return null;
|
|
175
|
+
const m = ISO_TS_RE.exec(v.trim());
|
|
176
|
+
if (!m) return null;
|
|
177
|
+
const [year, month, day, hour, minute, second] = [1, 2, 3, 4, 5, 6].map((i) => Number(m[i] ?? "0"));
|
|
178
|
+
if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return null;
|
|
179
|
+
if (hour > 23 || minute > 59 || second > 59) return null;
|
|
180
|
+
const ms = Date.parse(v.trim());
|
|
181
|
+
if (!Number.isFinite(ms)) return null;
|
|
182
|
+
const iso = new Date(ms).toISOString();
|
|
183
|
+
return /^(?!0000)\d{4}-/.test(iso) ? iso : null;
|
|
184
|
+
}
|
|
185
|
+
const INT4_MAX = 2_147_483_647;
|
|
186
|
+
const FIELD_RULES = {
|
|
187
|
+
provider: (v) => (v === "claude" || v === "codex" ? v : null),
|
|
188
|
+
usage_quality: (v) => (v === "measured" || v === "partial" ? v : null),
|
|
189
|
+
provider_schema_version: matching(VERSION_RE),
|
|
190
|
+
harness_session_id: (v) => uuid(v)?.toLowerCase() ?? null,
|
|
191
|
+
request_id: matching(PROVIDER_ID_RE),
|
|
192
|
+
message_id: matching(PROVIDER_ID_RE),
|
|
193
|
+
record_uuid: (v) => uuid(v)?.toLowerCase() ?? null,
|
|
194
|
+
occurred_at: isoTimestamp,
|
|
195
|
+
model: (v) => modelLabel(v),
|
|
196
|
+
git_branch: (v) => {
|
|
197
|
+
// Never path-shaped (Codex #982 R6): git itself forbids a leading '/', '//',
|
|
198
|
+
// '..' and a trailing '/', so such a value is a leaked path, not a branch.
|
|
199
|
+
const b = matching(BRANCH_RE)(v);
|
|
200
|
+
return b && !b.startsWith("/") && !b.endsWith("/") && !b.includes("//") && !b.includes("..") ? b : null;
|
|
201
|
+
},
|
|
202
|
+
repo: matching(REPO_RE),
|
|
203
|
+
commit_sha: (v) => (typeof v === "string" && SHA_RE.test(v.trim().toLowerCase()) ? v.trim().toLowerCase() : null),
|
|
204
|
+
ticket_key_inferred: (v) => (typeof v === "string" && TICKET_KEY_RE.test(v.trim().toUpperCase()) ? v.trim().toUpperCase() : null),
|
|
205
|
+
attribution_confidence: (v) => (ATTRIBUTION.includes(v) ? v : null),
|
|
206
|
+
environment: matching(ENV_RE),
|
|
207
|
+
cwd_hmac: matching(HMAC_RE),
|
|
208
|
+
source_hmac: matching(HMAC_RE),
|
|
209
|
+
parser_version: matching(VERSION_RE),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Build an envelope with EXACTLY the allowlisted keys, every string field
|
|
214
|
+
* passed through the server's rule. Unknown input keys are ignored; missing or
|
|
215
|
+
* rejected values become null; integers/booleans are coerced defensively.
|
|
216
|
+
*/
|
|
217
|
+
export function finalizeEnvelope(fields) {
|
|
218
|
+
const out = {};
|
|
219
|
+
for (const key of ENVELOPE_FIELDS) {
|
|
220
|
+
const value = fields[key];
|
|
221
|
+
if (INTEGER_FIELDS.has(key)) {
|
|
222
|
+
const n = tok(value);
|
|
223
|
+
out[key] = key === "tool_call_count" || key === "pr_number" ? (n !== null && n <= INT4_MAX ? n : null) : n;
|
|
224
|
+
}
|
|
225
|
+
else if (BOOLEAN_FIELDS.has(key)) out[key] = value === true;
|
|
226
|
+
else if (FIELD_RULES[key]) out[key] = FIELD_RULES[key](value);
|
|
227
|
+
else out[key] = typeof value === "string" && value.length > 0 ? value : null;
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Why the server would refuse this envelope outright (so it is never sent), else null. */
|
|
233
|
+
export function envelopeRefusal(envelope) {
|
|
234
|
+
if (!envelope.harness_session_id) return "harness_session_id_invalid";
|
|
235
|
+
if (!envelope.message_id) return "message_id_invalid";
|
|
236
|
+
if (!envelope.occurred_at) return "occurred_at_invalid";
|
|
237
|
+
if (!envelope.parser_version) return "parser_version_required";
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Attach the routing tenant (the tenant bound to the envelope's checkout, or
|
|
243
|
+
* null when unbound) as a NON-enumerable property: it steers which run may post
|
|
244
|
+
* the envelope but is never serialized, so the wire allowlist is unchanged.
|
|
245
|
+
*/
|
|
246
|
+
export function withRoute(envelope, tenant) {
|
|
247
|
+
Object.defineProperty(envelope, "route_tenant", { value: tenant ?? null, enumerable: false });
|
|
248
|
+
return envelope;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Assert no string value contains a path prefix or '@'. Reports field names and
|
|
253
|
+
* counts, never values, so the receipt of a failed check cannot itself leak.
|
|
254
|
+
*/
|
|
255
|
+
export function privacySelfCheck(rows) {
|
|
256
|
+
const violations = new Map();
|
|
257
|
+
rows.forEach((row, index) => {
|
|
258
|
+
for (const [field, value] of Object.entries(row)) {
|
|
259
|
+
if (typeof value !== "string") continue;
|
|
260
|
+
// A Vertex snapshot's '@YYYYMMDD' is the one permitted '@' (model only).
|
|
261
|
+
const checked = field === "model" && isVertexModel(value) ? value.slice(0, value.lastIndexOf("@")) : value;
|
|
262
|
+
if (FORBIDDEN_SUBSTRINGS.some((bad) => checked.includes(bad))) {
|
|
263
|
+
const entry = violations.get(field) ?? { field, count: 0, first_index: index };
|
|
264
|
+
entry.count += 1;
|
|
265
|
+
violations.set(field, entry);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
return { pass: violations.size === 0, rows_checked: rows.length, violations: [...violations.values()] };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── per-install HMAC key ─────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
export const HMAC_KEY_FILE = "usage-hmac-key";
|
|
275
|
+
|
|
276
|
+
export function hmacKeyPath(home = homedir()) {
|
|
277
|
+
return join(home, ".botbuddy", HMAC_KEY_FILE);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The per-install secret that keys cwd/source HMACs. A plain sha256 of a
|
|
282
|
+
* guessable path (`/Users/<name>/Developer/<repo>`) is dictionary-reversible; an
|
|
283
|
+
* HMAC under a random local key is not. Created 0600 with O_EXCL, so two racing
|
|
284
|
+
* first runs converge on one key. `create: false` (dry runs) never writes: it
|
|
285
|
+
* returns an ephemeral key when none exists yet.
|
|
286
|
+
*/
|
|
287
|
+
export async function loadHmacKey({ home = homedir(), create = true } = {}) {
|
|
288
|
+
const path = hmacKeyPath(home);
|
|
289
|
+
const read = async () => {
|
|
290
|
+
const text = (await readFile(path, "utf8")).trim();
|
|
291
|
+
if (!/^[0-9a-f]{64}$/.test(text)) throw Object.assign(new Error("usage HMAC key is malformed"), { code: "hmac_key_invalid" });
|
|
292
|
+
return Buffer.from(text, "hex");
|
|
293
|
+
};
|
|
294
|
+
try {
|
|
295
|
+
return await read();
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (error?.code !== "ENOENT") throw error;
|
|
298
|
+
}
|
|
299
|
+
const fresh = randomBytes(32);
|
|
300
|
+
if (!create) return fresh;
|
|
301
|
+
await mkdir(join(home, ".botbuddy"), { recursive: true, mode: 0o700 });
|
|
302
|
+
try {
|
|
303
|
+
await writeFile(path, `${fresh.toString("hex")}\n`, { mode: 0o600, flag: "wx" });
|
|
304
|
+
return fresh;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (error?.code === "EEXIST") return read();
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function hmac(key, value) {
|
|
312
|
+
return createHmac("sha256", key).update(String(value)).digest("hex");
|
|
313
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// BOT-1021 — the daily safety-net sweep as a macOS LaunchAgent.
|
|
2
|
+
//
|
|
3
|
+
// `bb setup` installs `ai.botbuddy.usage-sync` into the user's GUI launchd
|
|
4
|
+
// domain: it runs `bb usage sync --since 3d --trigger daily` at 03:10 local time
|
|
5
|
+
// and once at load (so a Mac that slept through 03:10 still sweeps on login).
|
|
6
|
+
// The GUI domain (not `user/<uid>` Background, like the stack canary) is what
|
|
7
|
+
// lets a stale worktree session self-heal from the login-Keychain client key.
|
|
8
|
+
// Elsewhere this is a no-op with a notice; the pre-push and Stop-hook triggers
|
|
9
|
+
// still run there.
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
export const USAGE_AGENT_LABEL = "ai.botbuddy.usage-sync";
|
|
20
|
+
export const USAGE_AGENT_HOUR = 3;
|
|
21
|
+
export const USAGE_AGENT_MINUTE = 10;
|
|
22
|
+
// --all-tenants: ONE job sweeps every tenant the login reaches, so running
|
|
23
|
+
// `bb setup` in another tenant's repo never narrows it (Codex #982 R6).
|
|
24
|
+
export const USAGE_AGENT_ARGS = Object.freeze(["usage", "sync", "--since", "3d", "--trigger", "daily", "--all-tenants"]);
|
|
25
|
+
|
|
26
|
+
export function usageAgentPlistPath(home = homedir()) {
|
|
27
|
+
return join(home, "Library", "LaunchAgents", `${USAGE_AGENT_LABEL}.plist`);
|
|
28
|
+
}
|
|
29
|
+
export function usageAgentLogPath(home = homedir()) {
|
|
30
|
+
return join(home, "Library", "Logs", "botbuddy-usage-sync.log");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The PATH baked into the plist. launchd gives a job almost nothing, and the
|
|
35
|
+
* installing shell's PATH is the wrong source: it carries per-shell dirs (fnm
|
|
36
|
+
* multishells, harness plugin dirs) that vanish when that shell exits. The sync
|
|
37
|
+
* needs node (absolute in ProgramArguments), git and security — so: node's own
|
|
38
|
+
* bin dir, then the standard system prefixes (the stack-canary rule, BOT-1751).
|
|
39
|
+
*/
|
|
40
|
+
export function usageAgentPath(nodeBin) {
|
|
41
|
+
return [dirname(nodeBin), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function xml(value) {
|
|
45
|
+
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function renderUsagePlist({ nodeBin, cliPath, workingDirectory, path, home, logPath }) {
|
|
49
|
+
const args = [nodeBin, cliPath, ...USAGE_AGENT_ARGS].map((a) => ` <string>${xml(a)}</string>`).join("\n");
|
|
50
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
51
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
52
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
53
|
+
<plist version="1.0">
|
|
54
|
+
<dict>
|
|
55
|
+
<key>Label</key>
|
|
56
|
+
<string>${xml(USAGE_AGENT_LABEL)}</string>
|
|
57
|
+
<key>ProgramArguments</key>
|
|
58
|
+
<array>
|
|
59
|
+
${args}
|
|
60
|
+
</array>
|
|
61
|
+
<key>WorkingDirectory</key>
|
|
62
|
+
<string>${xml(workingDirectory)}</string>
|
|
63
|
+
<key>EnvironmentVariables</key>
|
|
64
|
+
<dict>
|
|
65
|
+
<key>PATH</key>
|
|
66
|
+
<string>${xml(path)}</string>
|
|
67
|
+
<key>HOME</key>
|
|
68
|
+
<string>${xml(home)}</string>
|
|
69
|
+
</dict>
|
|
70
|
+
<key>StartCalendarInterval</key>
|
|
71
|
+
<dict>
|
|
72
|
+
<key>Hour</key>
|
|
73
|
+
<integer>${USAGE_AGENT_HOUR}</integer>
|
|
74
|
+
<key>Minute</key>
|
|
75
|
+
<integer>${USAGE_AGENT_MINUTE}</integer>
|
|
76
|
+
</dict>
|
|
77
|
+
<key>RunAtLoad</key>
|
|
78
|
+
<true/>
|
|
79
|
+
<key>KeepAlive</key>
|
|
80
|
+
<false/>
|
|
81
|
+
<key>StandardOutPath</key>
|
|
82
|
+
<string>${xml(logPath)}</string>
|
|
83
|
+
<key>StandardErrorPath</key>
|
|
84
|
+
<string>${xml(logPath)}</string>
|
|
85
|
+
<key>ProcessType</key>
|
|
86
|
+
<string>Background</string>
|
|
87
|
+
<key>Nice</key>
|
|
88
|
+
<integer>10</integer>
|
|
89
|
+
</dict>
|
|
90
|
+
</plist>
|
|
91
|
+
`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The main checkout of the repo `cwd` belongs to (stable across worktree deletion), else cwd. */
|
|
95
|
+
export async function mainCheckoutRoot(cwd, { exec = execFileAsync } = {}) {
|
|
96
|
+
try {
|
|
97
|
+
const { stdout } = await exec("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd });
|
|
98
|
+
const common = stdout.trim();
|
|
99
|
+
return common.endsWith("/.git") ? dirname(common) : cwd;
|
|
100
|
+
} catch {
|
|
101
|
+
return cwd;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Install (or confirm) the daily LaunchAgent. Idempotent: an identical plist
|
|
107
|
+
* that launchd already has loaded is left alone; a changed one is replaced
|
|
108
|
+
* (bootout → bootstrap). Returns { status: installed|unchanged|unsupported|failed }.
|
|
109
|
+
*/
|
|
110
|
+
export async function installUsageAgent({
|
|
111
|
+
platform = process.platform, home = homedir(), cwd = process.cwd(), uid = process.getuid?.() ?? 0,
|
|
112
|
+
nodeBin = process.execPath, cliPath = fileURLToPath(new URL("../../bin/botbuddy.mjs", import.meta.url)),
|
|
113
|
+
path = usageAgentPath(nodeBin), exec = execFileAsync, read = readFile, write = writeFile, makeDir = mkdir,
|
|
114
|
+
} = {}) {
|
|
115
|
+
if (platform !== "darwin") {
|
|
116
|
+
return { status: "unsupported", notice: "the daily usage sweep uses launchd (macOS only); pre-push and session hooks still sync here" };
|
|
117
|
+
}
|
|
118
|
+
const plistPath = usageAgentPlistPath(home);
|
|
119
|
+
const plist = renderUsagePlist({
|
|
120
|
+
nodeBin, cliPath, workingDirectory: await mainCheckoutRoot(cwd, { exec }), path, home, logPath: usageAgentLogPath(home),
|
|
121
|
+
});
|
|
122
|
+
const domain = `gui/${uid}`;
|
|
123
|
+
let existing = null;
|
|
124
|
+
try { existing = await read(plistPath, "utf8"); } catch { existing = null; }
|
|
125
|
+
if (existing === plist) {
|
|
126
|
+
try {
|
|
127
|
+
await exec("launchctl", ["print", `${domain}/${USAGE_AGENT_LABEL}`]);
|
|
128
|
+
return { status: "unchanged" };
|
|
129
|
+
} catch { /* on disk but not loaded: fall through and load it */ }
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
await makeDir(dirname(plistPath), { recursive: true });
|
|
133
|
+
await makeDir(dirname(usageAgentLogPath(home)), { recursive: true });
|
|
134
|
+
await write(plistPath, plist, { mode: 0o644 });
|
|
135
|
+
// bootout first so a re-install replaces the loaded job; it fails harmlessly on a first install.
|
|
136
|
+
await exec("launchctl", ["bootout", `${domain}/${USAGE_AGENT_LABEL}`]).catch(() => {});
|
|
137
|
+
await exec("launchctl", ["bootstrap", domain, plistPath]);
|
|
138
|
+
return { status: "installed" };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return { status: "failed", error: error?.code ?? "launchctl_failed" };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Doctor's view. `installed` means launchd actually HAS the job: a plist on
|
|
146
|
+
* disk that launchd never loaded (a failed bootstrap, a later bootout) is
|
|
147
|
+
* reported as not installed, so a dead daily sweep is never shown as healthy.
|
|
148
|
+
*/
|
|
149
|
+
export async function usageAgentStatus({
|
|
150
|
+
platform = process.platform, home = homedir(), uid = process.getuid?.() ?? 0,
|
|
151
|
+
read = readFile, exec = execFileAsync,
|
|
152
|
+
} = {}) {
|
|
153
|
+
if (platform !== "darwin") return { supported: false, installed: false };
|
|
154
|
+
let plist = false;
|
|
155
|
+
try {
|
|
156
|
+
const text = await read(usageAgentPlistPath(home), "utf8");
|
|
157
|
+
plist = text.includes(`<string>${USAGE_AGENT_LABEL}</string>`);
|
|
158
|
+
} catch {
|
|
159
|
+
plist = false;
|
|
160
|
+
}
|
|
161
|
+
if (!plist) return { supported: true, installed: false };
|
|
162
|
+
let loaded = false;
|
|
163
|
+
try {
|
|
164
|
+
await exec("launchctl", ["print", `gui/${uid}/${USAGE_AGENT_LABEL}`]);
|
|
165
|
+
loaded = true;
|
|
166
|
+
} catch {
|
|
167
|
+
loaded = false;
|
|
168
|
+
}
|
|
169
|
+
return loaded ? { supported: true, installed: true } : { supported: true, installed: false, plist: true, loaded: false };
|
|
170
|
+
}
|