@botbuddy/cli 1.2.3 → 1.4.1

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.
@@ -0,0 +1,122 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ // BOT-1382: the CLI publish workflow is the artifact under test. These
6
+ // contract assertions make its trigger, working directory, skip behaviour,
7
+ // and post-publish verification reviewable locally, without a YAML parser
8
+ // dependency (the CLI package intentionally has none).
9
+ const wf = readFileSync(
10
+ new URL("../../.github/workflows/publish-cli-package.yml", import.meta.url),
11
+ "utf8",
12
+ );
13
+
14
+ test("BOT-1382: triggers ONLY on main pushes touching cli/ or the workflow — no credential-bearing dispatch", () => {
15
+ assert.match(wf, /on:/);
16
+ assert.match(wf, /branches:\s*\[main\]/);
17
+ assert.match(wf, /- "cli\/\*\*"/);
18
+ assert.match(wf, /- "\.github\/workflows\/publish-cli-package\.yml"/);
19
+ // workflow_dispatch is intentionally NOT enabled: it accepts an arbitrary
20
+ // --ref, so a feature branch could run its own (guard-stripped) workflow with
21
+ // NPM_TOKEN. The on: block must not declare it.
22
+ assert.doesNotMatch(wf, /^\s*workflow_dispatch:/m);
23
+ });
24
+
25
+ test("BOT-1382: read-only contents permission and a static, non-cancelling concurrency group", () => {
26
+ assert.match(wf, /permissions:\s*\n\s*contents: read/);
27
+ assert.match(wf, /cancel-in-progress: false/);
28
+ // A STATIC group serializes publishes so the backward-move guard is correct
29
+ // (a per-commit key would let runs race the guard and corrupt the dist-tag).
30
+ assert.match(wf, /group: publish-cli-package\n/);
31
+ assert.doesNotMatch(wf, /group: publish-cli-package-\$\{\{ github\.sha \}\}/);
32
+ });
33
+
34
+ test("BOT-1382: Node 20 on the public npm registry", () => {
35
+ assert.match(wf, /node-version: 20/);
36
+ assert.match(wf, /registry-url: "https:\/\/registry\.npmjs\.org"/);
37
+ });
38
+
39
+ test("BOT-1382: fails loudly on a missing NPM_TOKEN and never echoes it", () => {
40
+ assert.match(wf, /NPM_TOKEN repo secret is not set/);
41
+ assert.match(wf, /secrets\.NPM_TOKEN/);
42
+ // The token must only ever flow through env, never be printed.
43
+ assert.doesNotMatch(wf, /echo[^\n]*\$\{?NPM_TOKEN/);
44
+ });
45
+
46
+ test("BOT-1382: reads name and version dynamically and never hard-codes the version", () => {
47
+ assert.match(wf, /require\('\.\/cli\/package\.json'\)\.name/);
48
+ assert.match(wf, /require\('\.\/cli\/package\.json'\)\.version/);
49
+ assert.doesNotMatch(wf, /1\.4\.0/);
50
+ });
51
+
52
+ test("BOT-1382: prerelease is classified structurally, not by a raw hyphen", () => {
53
+ assert.match(wf, /publish-equal\.mjs" --is-prerelease/);
54
+ // The naive raw-hyphen classifier must be gone (build metadata like
55
+ // 1.4.0+build-1 is a stable release).
56
+ assert.doesNotMatch(wf, /\[\[ "\$LOCAL" == \*-\* \]\]/);
57
+ });
58
+
59
+ test("BOT-1382: the exact-version probe fails closed — only E404 means unpublished", () => {
60
+ const check = wf.slice(wf.indexOf("Decide publish vs skip"), wf.indexOf("Guard against moving a dist-tag backward"));
61
+ assert.match(check, /E404/);
62
+ assert.match(check, /registry error/);
63
+ assert.doesNotMatch(check, /npm view "\$\{PKG_NAME\}@\$\{LOCAL\}" version >\/dev\/null 2>&1/);
64
+ });
65
+
66
+ test("BOT-1382: runs the full CLI test suite before publishing", () => {
67
+ assert.match(wf, /node --test cli\/src\/\*\.test\.mjs/);
68
+ });
69
+
70
+ test("BOT-1382: performs an npm pack dry-run from cli/ before publishing", () => {
71
+ assert.match(wf, /working-directory: cli/);
72
+ assert.match(wf, /npm pack --dry-run/);
73
+ });
74
+
75
+ test("BOT-1382: skips an already-published version and otherwise publishes with public access", () => {
76
+ assert.match(wf, /npm view "\$\{PKG_NAME\}@\$\{LOCAL\}" version/);
77
+ assert.match(wf, /npm publish --access public/);
78
+ assert.match(wf, /if: steps\.check\.outputs\.published == 'false'/);
79
+ });
80
+
81
+ test("BOT-1382: verifies the public artifact — exact version, latest tag, and profile/wait --help", () => {
82
+ assert.match(wf, /npm view "\$\{PKG_NAME\}@\$\{LOCAL\}" version/);
83
+ assert.match(wf, /npm view "\$\{PKG_NAME\}@latest" version/);
84
+ assert.match(wf, /profile --help/);
85
+ assert.match(wf, /wait --help/);
86
+ });
87
+
88
+ test("BOT-1382: a changed CLI that reuses a published version fails instead of silently skipping", () => {
89
+ // Only a byte-identical published package is an idempotent skip; a changed
90
+ // cli/ that reused a published (immutable) version must fail loudly. The
91
+ // decision is delegated to the unit-tested publish-equal helper.
92
+ assert.match(wf, /dist\.tarball/);
93
+ assert.match(wf, /versions are immutable/);
94
+ assert.match(wf, /cli\/src\/publish-equal\.mjs/);
95
+ });
96
+
97
+ test("BOT-1382: prereleases publish under a non-latest tag and must not move latest", () => {
98
+ assert.match(wf, /--tag next/);
99
+ assert.match(wf, /moved the latest dist-tag/);
100
+ });
101
+
102
+ test("BOT-1382: a publish that would move a dist-tag backward is refused (latest for stable, next for prerelease)", () => {
103
+ assert.match(wf, /Guard against moving a dist-tag backward/);
104
+ assert.match(wf, /publish-equal\.mjs" --gt/);
105
+ assert.match(wf, /move the \$\{TAG\} dist-tag backward/);
106
+ // The guarded tag is chosen by prerelease-ness: next for prereleases, latest otherwise.
107
+ assert.match(wf, /if \[\[ "\$PRERELEASE" == "true" \]\]; then TAG="next"; else TAG="latest"; fi/);
108
+ });
109
+
110
+ test("BOT-1382: publishing is restricted to main even via workflow_dispatch", () => {
111
+ assert.match(wf, /must only run on main/);
112
+ assert.match(wf, /GITHUB_REF.*!= "refs\/heads\/main"|"\$\{GITHUB_REF\}" != "refs\/heads\/main"/);
113
+ });
114
+
115
+ test("BOT-1382: the backward-move guard reads the dist-tag fail-closed — a registry error aborts, only E404 is a first publish", () => {
116
+ // A non-404 read failure must abort rather than be treated as "no tag".
117
+ const guard = wf.slice(wf.indexOf("Guard against moving a dist-tag backward"));
118
+ assert.match(guard, /E404/);
119
+ assert.match(guard, /registry error/);
120
+ // The guard must NOT swallow read errors into an empty "first publish".
121
+ assert.doesNotMatch(guard.slice(0, guard.indexOf("Publish to npm")), /2>\/dev\/null \|\| true/);
122
+ });
@@ -0,0 +1,134 @@
1
+ // BOT-816 — host-side quiet command runner.
2
+ //
3
+ // This module deliberately owns process polling outside an LLM. It is
4
+ // dependency-free so adapters can use it from the published CLI as well as
5
+ // embed it in an editor integration. The API boundary sends only metadata;
6
+ // the complete output remains in the receipt file.
7
+ import { createReadStream } from "node:fs";
8
+ import { appendFile, mkdir, stat, writeFile } from "node:fs/promises";
9
+ import { dirname, resolve } from "node:path";
10
+ import { createHash, randomUUID } from "node:crypto";
11
+
12
+ const MAX_SNAPSHOT_CHARS = 800;
13
+ const MAX_FAILURE_TAIL_BYTES = 2048;
14
+ const MAX_FAILURE_CAPTURE_BYTES = 64 * 1024;
15
+
16
+ function boundedText(value, maxChars = 64) {
17
+ const text = String(value ?? "");
18
+ return text.length <= maxChars ? text : `${text.slice(0, Math.max(0, maxChars - 1))}…`;
19
+ }
20
+
21
+ export function boundedSnapshot({ status, elapsedSeconds, transition, receiptPath, nextWakeCondition }) {
22
+ // Bound fields before serializing so callers always receive parseable JSON.
23
+ // 64-character fields remain comfortably below the 800-character contract,
24
+ // even when every character needs JSON escaping.
25
+ return JSON.stringify({
26
+ status: boundedText(status, 32),
27
+ elapsed_seconds: Math.max(0, Math.floor(elapsedSeconds)),
28
+ last_meaningful_transition: boundedText(transition, 64) || null,
29
+ receipt_path: boundedText(receiptPath, 64),
30
+ next_wake_condition: boundedText(nextWakeCondition, 64),
31
+ });
32
+ }
33
+
34
+ function redactFailureText(text) {
35
+ return String(text)
36
+ .replace(/(authorization\s*:\s*bearer\s+)[^\s,;]+/gi, "$1[REDACTED]")
37
+ .replace(/((?:"|')(?:api[_-]?key|token|password|secret)(?:"|')\s*:\s*(?:"|'))[^"']*((?:"|'))/gi, "$1[REDACTED]$2")
38
+ .replace(/((?:api[_-]?key|token|password|secret)\s*[=:]\s*)[^\s]+/gi, "$1[REDACTED]")
39
+ .replace(/\b(?:bb_[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]+|ghp_[A-Za-z0-9_-]+)\b/g, "[REDACTED]");
40
+ }
41
+
42
+ export function failureTail(text) {
43
+ // Redact before taking the tail: a long secret may otherwise lose its
44
+ // `token=`/`Authorization:` prefix at the truncation boundary.
45
+ const redacted = redactFailureText(text);
46
+ return Buffer.from(redacted).subarray(-MAX_FAILURE_TAIL_BYTES).toString("utf8");
47
+ }
48
+
49
+ function appendFailureCapture(current, chunk) {
50
+ // Redact the combined bounded history before truncating it. Keeping the
51
+ // unredacted (but capped) boundary context lets `token=` in one poll and its
52
+ // value in the next poll be recognised as one credential before its label
53
+ // would otherwise fall outside the capture window.
54
+ const combined = current + String(chunk);
55
+ const redacted = redactFailureText(combined);
56
+ const safe = redacted === combined ? combined : redacted;
57
+ return safe.length <= MAX_FAILURE_CAPTURE_BYTES ? safe : safe.slice(-MAX_FAILURE_CAPTURE_BYTES);
58
+ }
59
+
60
+ async function sha256File(path) {
61
+ return await new Promise((resolve, reject) => {
62
+ const hash = createHash("sha256");
63
+ const input = createReadStream(path);
64
+ input.on("data", (chunk) => hash.update(chunk));
65
+ input.once("error", reject);
66
+ input.once("end", () => resolve(hash.digest("hex")));
67
+ });
68
+ }
69
+
70
+ export class QuietRunner {
71
+ constructor({ launch, notify, heartbeat, artifactDir, now = () => Date.now(), pollIntervalMs = 1_000 }) {
72
+ this.launch = launch;
73
+ this.notify = notify;
74
+ this.heartbeat = heartbeat;
75
+ this.artifactDir = artifactDir;
76
+ this.now = now;
77
+ this.pollIntervalMs = pollIntervalMs;
78
+ }
79
+
80
+ async run({ runId = randomUUID(), command, receiptPath, receiptUri, onTransition }) {
81
+ // `receiptPath` is the local artifact path. A registered command also has
82
+ // a server-issued receipt URI; that URI is metadata, never a filesystem
83
+ // path. Keeping the two separate prevents `resolve("receipt://…")` from
84
+ // turning a valid upload target into an invalid local absolute path.
85
+ const registeredReceiptUri = receiptUri ?? (typeof receiptPath === "string" && /^(artifact|receipt):\/\//.test(receiptPath) ? receiptPath : undefined);
86
+ const path = resolve(registeredReceiptUri ? `${this.artifactDir}/${runId}.log` : (receiptPath ?? `${this.artifactDir}/${runId}.log`));
87
+ const reportedReceiptPath = registeredReceiptUri ?? path;
88
+ await mkdir(dirname(path), { recursive: true });
89
+ await writeFile(path, "");
90
+ const started = this.now();
91
+ const process = await this.launch(command);
92
+ let pollCount = 0;
93
+ let unchangedPollCount = 0;
94
+ let lastTransition = "started";
95
+ let outputCharacters = 0;
96
+ let failureCapture = "";
97
+ let terminal;
98
+
99
+ for await (const update of process.poll(this.pollIntervalMs)) {
100
+ pollCount += 1;
101
+ if (update.output) {
102
+ outputCharacters += update.output.length;
103
+ failureCapture = appendFailureCapture(failureCapture, update.output);
104
+ await appendFile(path, update.output);
105
+ }
106
+ if (update.transition) {
107
+ lastTransition = update.transition;
108
+ unchangedPollCount = 0;
109
+ const snapshot = boundedSnapshot({ status: "running", elapsedSeconds: (this.now() - started) / 1000, transition: lastTransition, receiptPath: reportedReceiptPath, nextWakeCondition: "terminal_failure_approval_or_transition" });
110
+ await onTransition?.({ run_id: runId, status: "running", meaningful_transition: lastTransition, snapshot, poll_count: pollCount, unchanged_poll_count: unchangedPollCount, receipt_path: reportedReceiptPath });
111
+ await this.notify?.({ run_id: runId, reason: "meaningful_transition", snapshot });
112
+ } else {
113
+ unchangedPollCount += 1;
114
+ }
115
+ await this.heartbeat?.({ run_id: runId, status: "running" });
116
+ if (update.terminal) { terminal = update; break; }
117
+ }
118
+
119
+ const size = (await stat(path)).size;
120
+ const receipt = { path, artifact_uri: registeredReceiptUri, sha256: await sha256File(path), bytes: size };
121
+ const status = terminal?.exitCode === 0 ? "succeeded" : "failed";
122
+ const result = {
123
+ run_id: runId, status, exit_code: terminal?.exitCode ?? 1, receipt_path: reportedReceiptPath,
124
+ receipt, poll_count: pollCount, unchanged_poll_count: unchangedPollCount,
125
+ output_characters: outputCharacters, original_output_tokens: Math.ceil(outputCharacters / 4),
126
+ delivered_output_tokens: 0, truncated: outputCharacters > 0,
127
+ failure: status === "failed" ? { exit_code: terminal?.exitCode ?? 1, receipt_path: reportedReceiptPath, tail: failureTail(failureCapture) } : undefined,
128
+ };
129
+ await onTransition?.(result);
130
+ await this.notify?.({ run_id: runId, reason: status === "failed" ? "failure" : "terminal", ...(status === "failed" ? { failure: result.failure } : { receipt_path: reportedReceiptPath }) });
131
+ await this.heartbeat?.({ run_id: runId, status: "terminal" });
132
+ return result;
133
+ }
134
+ }
@@ -0,0 +1,109 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, readFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { QuietRunner, boundedSnapshot, failureTail } from "./quiet-runner.mjs";
7
+
8
+ test("quiet runner writes complete output while waking only for transitions and terminal state", async () => {
9
+ const artifactDir = await mkdtemp(join(tmpdir(), "botbuddy-quiet-runner-"));
10
+ const notifications = []; const heartbeats = [];
11
+ const runner = new QuietRunner({
12
+ artifactDir, now: (() => { let now = 0; return () => (now += 1_000); })(),
13
+ launch: async () => ({ async *poll() { yield { output: "waiting\\n" }; yield { output: "ready\\n", transition: "tests_started" }; yield { output: "done\\n", terminal: true, exitCode: 0 }; } }),
14
+ notify: async (value) => notifications.push(value), heartbeat: async (value) => heartbeats.push(value),
15
+ });
16
+ const result = await runner.run({ runId: "run-1", command: ["pnpm", "test"] });
17
+ assert.equal(result.status, "succeeded");
18
+ assert.equal(await readFile(result.receipt_path, "utf8"), "waiting\\nready\\ndone\\n");
19
+ assert.equal(notifications.length, 2, "unchanged polls never wake the agent");
20
+ assert.equal(notifications[0].reason, "meaningful_transition");
21
+ assert.equal(notifications[1].reason, "terminal");
22
+ assert.equal(heartbeats.at(-1).status, "terminal");
23
+ });
24
+
25
+ test("quiet runner sanitizes failure and snapshots stay bounded", async () => {
26
+ const artifactDir = await mkdtemp(join(tmpdir(), "botbuddy-quiet-runner-"));
27
+ const runner = new QuietRunner({ artifactDir, launch: async () => ({ async *poll() { yield { output: `token=super-secret\\n${"x".repeat(3_000)}`, terminal: true, exitCode: 1 }; } }) });
28
+ const result = await runner.run({ runId: "run-2", command: ["false"] });
29
+ assert.equal(result.failure.exit_code, 1);
30
+ assert.equal(result.failure.tail.length <= 2048, true);
31
+ assert.equal(result.failure.tail.includes("super-secret"), false);
32
+ assert.equal(boundedSnapshot({ status: "running", elapsedSeconds: 1, transition: "x".repeat(5_000), receiptPath: "receipt://x", nextWakeCondition: "terminal" }).length <= 800, true);
33
+ });
34
+
35
+ test("quiet runner redacts bearer headers and known credential prefixes", () => {
36
+ const tail = failureTail("Authorization: Bearer bb_live_abc123\\nsk-proj-secret\\nghp_githubsecret\\ntoken=plain-secret\\n{\"password\":\"hunter2\",\"api_key\":\"opaque-secret\"}");
37
+ assert.equal(tail.includes("bb_live_abc123"), false);
38
+ assert.equal(tail.includes("sk-proj-secret"), false);
39
+ assert.equal(tail.includes("ghp_githubsecret"), false);
40
+ assert.equal(tail.includes("plain-secret"), false);
41
+ assert.equal(tail.includes("hunter2"), false);
42
+ assert.equal(tail.includes("opaque-secret"), false);
43
+ assert.match(tail, /Authorization: Bearer \[REDACTED\]/);
44
+ });
45
+
46
+ test("quiet runner redacts a credential even when it crosses the failure-tail boundary", () => {
47
+ const secret = "x".repeat(3_000);
48
+ const tail = failureTail(`token=${secret}`);
49
+ assert.equal(tail.includes(secret.slice(-64)), false);
50
+ assert.match(tail, /token=\[REDACTED\]/);
51
+ });
52
+
53
+ test("quiet runner redacts a credential before bounded capture drops its label", async () => {
54
+ const artifactDir = await mkdtemp(join(tmpdir(), "botbuddy-quiet-runner-"));
55
+ const secret = "x".repeat(70_000);
56
+ const runner = new QuietRunner({ artifactDir, launch: async () => ({ async *poll() { yield { output: `token=${secret}`, terminal: true, exitCode: 1 }; } }) });
57
+ const result = await runner.run({ runId: "large-secret", command: ["false"] });
58
+ assert.equal(result.failure.tail.includes(secret.slice(-64)), false);
59
+ assert.match(result.failure.tail, /token=\[REDACTED\]/);
60
+ });
61
+
62
+ test("quiet runner redacts a credential whose label and value arrive in separate polls", async () => {
63
+ const artifactDir = await mkdtemp(join(tmpdir(), "botbuddy-quiet-runner-"));
64
+ const secret = "x".repeat(70_000);
65
+ const runner = new QuietRunner({ artifactDir, launch: async () => ({ async *poll() {
66
+ yield { output: "token=" };
67
+ yield { output: secret, terminal: true, exitCode: 1 };
68
+ } }) });
69
+ const result = await runner.run({ runId: "split-large-secret", command: ["false"] });
70
+ assert.equal(result.failure.tail.includes(secret.slice(-64)), false);
71
+ assert.match(result.failure.tail, /token=\[REDACTED\]/);
72
+ });
73
+
74
+ test("bounded snapshots remain parseable JSON when fields are oversized", () => {
75
+ const snapshot = boundedSnapshot({ status: "running", elapsedSeconds: 1, transition: '"'.repeat(5_000), receiptPath: "receipt://" + "\\".repeat(5_000), nextWakeCondition: "x".repeat(5_000) });
76
+ assert.equal(snapshot.length <= 800, true);
77
+ assert.equal(JSON.parse(snapshot).status, "running");
78
+ });
79
+
80
+ test("quiet runner keeps only bounded failure state while recording a large receipt", async () => {
81
+ const artifactDir = await mkdtemp(join(tmpdir(), "botbuddy-quiet-runner-"));
82
+ const output = "x".repeat(200_000);
83
+ const runner = new QuietRunner({ artifactDir, launch: async () => ({ async *poll() { yield { output, terminal: true, exitCode: 1 }; } }) });
84
+ const result = await runner.run({ runId: "large-output", command: ["false"] });
85
+ assert.equal(result.output_characters, output.length);
86
+ assert.equal((await readFile(result.receipt_path)).length, output.length);
87
+ assert.equal(result.failure.tail.length <= 2048, true);
88
+ });
89
+
90
+ test("quiet runner preserves a registered receipt URI while writing its local artifact", async () => {
91
+ const artifactDir = await mkdtemp(join(tmpdir(), "botbuddy-quiet-runner-"));
92
+ const transitions = [];
93
+ const runner = new QuietRunner({
94
+ artifactDir,
95
+ launch: async () => ({ async *poll() { yield { output: "complete\\n", transition: "finished", terminal: true, exitCode: 0 }; } }),
96
+ });
97
+
98
+ const result = await runner.run({
99
+ runId: "registered-receipt",
100
+ command: ["pnpm", "test"],
101
+ receiptPath: "receipt://tenant/registered-receipt",
102
+ onTransition: async (transition) => transitions.push(transition),
103
+ });
104
+
105
+ assert.equal(result.receipt_path, "receipt://tenant/registered-receipt");
106
+ assert.equal(result.receipt.artifact_uri, "receipt://tenant/registered-receipt");
107
+ assert.equal(await readFile(result.receipt.path, "utf8"), "complete\\n");
108
+ assert.equal(transitions[0].receipt_path, "receipt://tenant/registered-receipt");
109
+ });
package/src/run.mjs ADDED
@@ -0,0 +1,239 @@
1
+ // BOT-1350 — a durable local owner for receipt-bearing commands.
2
+ //
3
+ // The launcher intentionally exits after it has registered a run and handed a
4
+ // sealed context to a detached worker. The worker creates its own process
5
+ // group for the workload, writes exactly one receipt, and is the only process
6
+ // allowed to call update_command_run. A bb-wait is therefore a notification
7
+ // mechanism, never the owner of a child process.
8
+
9
+ import { createHash, randomUUID } from "crypto";
10
+ import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "fs/promises";
11
+ import { dirname, join } from "path";
12
+ import { homedir } from "os";
13
+ import { spawn } from "child_process";
14
+ import { fileURLToPath } from "url";
15
+ import { callToolJson } from "./api.mjs";
16
+
17
+ export const RUN_SCHEMA_VERSION = 1;
18
+ export const EXIT = Object.freeze({ OK: 0, INVALID: 4, BACKEND: 5, INTERNAL: 7 });
19
+ export const DEFAULT_TIMEOUT_SECONDS = 8 * 60 * 60;
20
+ export const TIMEOUT_GRACE_MS = 5_000;
21
+ const MAX_CAPTURE_BYTES = 8_192;
22
+
23
+ export function parseRunArgs(argv) {
24
+ const opts = { sessionId: null, environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, json: false };
25
+ const errors = [];
26
+ const separator = argv.indexOf("--");
27
+ const flags = separator === -1 ? argv : argv.slice(0, separator);
28
+ const command = separator === -1 ? [] : argv.slice(separator + 1);
29
+ const value = (name, index) => {
30
+ if (flags[index + 1] == null) { errors.push(`${name} needs a value`); return null; }
31
+ return flags[index + 1];
32
+ };
33
+ for (let i = 0; i < flags.length; i++) {
34
+ const flag = flags[i];
35
+ switch (flag) {
36
+ case "--session-id": opts.sessionId = value(flag, i); i++; break;
37
+ case "--environment": opts.environment = value(flag, i); i++; break;
38
+ case "--category": opts.category = value(flag, i); i++; break;
39
+ case "--kind": opts.kind = value(flag, i); i++; break;
40
+ case "--expected-duration": opts.expectedDuration = Number(value(flag, i)); i++; break;
41
+ case "--timeout": opts.timeout = Number(value(flag, i)); i++; break;
42
+ case "--rerun-reason": {
43
+ const raw = value(flag, i); i++;
44
+ try {
45
+ const parsed = JSON.parse(raw);
46
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("must be a JSON object");
47
+ opts.rerunReason = parsed;
48
+ } catch (error) { errors.push(`--rerun-reason must be a JSON object (${error instanceof Error ? error.message : String(error)})`); }
49
+ break;
50
+ }
51
+ case "--json": opts.json = true; break;
52
+ default: errors.push(`unknown option: ${flag}`);
53
+ }
54
+ }
55
+ if (!opts.sessionId) errors.push("--session-id is required (from register_agent)");
56
+ if (!opts.environment || !["local", "preview", "staging", "production", "none"].includes(opts.environment)) errors.push("--environment must be local, preview, staging, production, or none");
57
+ if (!command.length) errors.push("a workload is required after --");
58
+ if (!Number.isInteger(opts.expectedDuration) || opts.expectedDuration < 0) errors.push("--expected-duration must be a non-negative integer");
59
+ if (!Number.isInteger(opts.timeout) || opts.timeout <= 0 || opts.timeout > DEFAULT_TIMEOUT_SECONDS) errors.push(`--timeout must be a positive integer <= ${DEFAULT_TIMEOUT_SECONDS}`);
60
+ return { opts, command, errors };
61
+ }
62
+
63
+ export function terminalStatus({ exitCode, signal, timedOut = false, ownerLost = false }) {
64
+ if (ownerLost) return "owner_lost";
65
+ if (timedOut) return "timed_out";
66
+ if (signal) return "cancelled";
67
+ return exitCode === 0 ? "success" : "failure";
68
+ }
69
+
70
+ export function commandHash(command) { return createHash("sha256").update(JSON.stringify(command)).digest("hex"); }
71
+ export function environmentHash(cwd, environment) { return createHash("sha256").update(`${cwd}\0${environment}`).digest("hex"); }
72
+ export function receiptPath(runId) { return join(homedir(), ".botbuddy", "command-runs", `${runId}.receipt.json`); }
73
+ export function contextPath(runId) { return join(homedir(), ".botbuddy", "command-runs", `${runId}.context.json`); }
74
+
75
+ async function writeJson(path, value) {
76
+ await mkdir(dirname(path), { recursive: true });
77
+ // Write-then-rename so a concurrent reader (cancelRun reading the context
78
+ // file, or a poller) never observes a torn/empty file mid-write. rename(2)
79
+ // is atomic within a directory, so readers see either the old or the new
80
+ // complete JSON, never a partial one.
81
+ const tmp = `${path}.${process.pid}.tmp`;
82
+ await writeFile(tmp, JSON.stringify(value), { mode: 0o600 });
83
+ await chmod(tmp, 0o600);
84
+ await rename(tmp, path);
85
+ }
86
+
87
+ function oneLine(value, pretty) { process.stdout.write(`${JSON.stringify(value, null, pretty ? 2 : 0)}\n`); }
88
+
89
+ function workerInvocation(context) {
90
+ const bin = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "botbuddy.mjs");
91
+ return [process.execPath, [bin, "run", "--worker", context]];
92
+ }
93
+
94
+ export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn, call = callToolJson } = {}) {
95
+ const { opts, command, errors } = parseRunArgs(argv);
96
+ if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
97
+ const runId = randomUUID();
98
+ const registration = await call("register_command", {
99
+ session_id: opts.sessionId, run_id: runId, tool_category: opts.category,
100
+ command_hash: commandHash(command), environment_hash: environmentHash(cwd, opts.environment),
101
+ environment: opts.environment, command_kind: opts.kind, expected_duration_seconds: opts.expectedDuration,
102
+ rerun_reason: opts.rerunReason,
103
+ });
104
+ if (!registration.ok || registration.isError || !registration.data?.accepted || !registration.data?.run_credential) {
105
+ return { exitCode: EXIT.BACKEND, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", run_id: runId, error: registration.data?.error ?? registration.error ?? "durable_owner_not_established", exit_code: EXIT.BACKEND } };
106
+ }
107
+ const context = contextPath(runId);
108
+ const receipt = receiptPath(runId);
109
+ await writeJson(context, { schema_version: RUN_SCHEMA_VERSION, context_path: context, run_id: runId, run_credential: registration.data.run_credential, receipt_upload: registration.data.receipt_upload, command, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString() });
110
+ const [file, args] = workerInvocation(context);
111
+ try {
112
+ const worker = spawnImpl(file, args, { detached: true, stdio: "ignore", windowsHide: true });
113
+ worker.unref();
114
+ } catch (error) {
115
+ await call("update_command_run", { run_id: runId, run_credential: registration.data.run_credential, status: "owner_lost", invalidated_reason: `launcher_failed:${error instanceof Error ? error.message : String(error)}` });
116
+ return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "owner_lost", run_id: runId, exit_code: EXIT.INTERNAL } };
117
+ }
118
+ return { exitCode: EXIT.OK, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "launched", run_id: runId, session_id: opts.sessionId, durable_owner: "detached_worker", receipt_path: receipt, reentry: "update_command_run publishes the terminal callback for this run_id", exit_code: EXIT.OK } };
119
+ }
120
+
121
+ export async function runWorker(contextFile, { spawnImpl = spawn, call = callToolJson } = {}) {
122
+ const context = JSON.parse(await readFile(contextFile, "utf8"));
123
+ const startedAt = new Date().toISOString();
124
+ let timedOut = false;
125
+ let child;
126
+ try {
127
+ child = spawnImpl(context.command[0], context.command.slice(1), { cwd: context.cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
128
+ } catch (error) {
129
+ return finalize(context, { status: "owner_lost", startedAt, endedAt: new Date().toISOString(), exitCode: null, signal: null, reason: `workload_spawn_failed:${error instanceof Error ? error.message : String(error)}` }, call);
130
+ }
131
+ // A failed spawn can report through the child `error` event on the next turn.
132
+ // Subscribe before any filesystem I/O so a busy parallel test run (or a real
133
+ // fast failure) cannot lose the sole terminal event.
134
+ const completion = waitForChild(child);
135
+ await writeJson(context.context_path, { ...context, worker_pid: process.pid, workload_pid: child.pid });
136
+ const capture = boundedCapture();
137
+ child.stdout?.on("data", (chunk) => capture.append(chunk));
138
+ child.stderr?.on("data", (chunk) => capture.append(chunk));
139
+ let killTimer;
140
+ const timer = setTimeout(() => {
141
+ timedOut = true;
142
+ terminateProcessGroup(child, "SIGTERM");
143
+ killTimer = setTimeout(() => terminateProcessGroup(child, "SIGKILL"), context.timeout_grace_ms ?? TIMEOUT_GRACE_MS);
144
+ }, context.timeout_seconds * 1000);
145
+ const result = await completion;
146
+ clearTimeout(timer);
147
+ clearTimeout(killTimer);
148
+ if (result.error) {
149
+ return finalize(context, { status: "owner_lost", startedAt, endedAt: new Date().toISOString(), exitCode: null, signal: null, reason: `workload_spawn_failed:${result.error.message}`, output: capture.output, outputCharacters: capture.characters }, call);
150
+ }
151
+ const status = terminalStatus({ ...result, timedOut });
152
+ return finalize(context, { status, startedAt, endedAt: new Date().toISOString(), exitCode: result.exitCode, signal: result.signal, output: capture.output, outputCharacters: capture.characters }, call);
153
+ }
154
+
155
+ function boundedCapture() {
156
+ let tail = Buffer.alloc(0);
157
+ let characters = 0;
158
+ return {
159
+ append(chunk) {
160
+ const bytes = Buffer.from(chunk);
161
+ characters += bytes.toString("utf8").length;
162
+ tail = Buffer.concat([tail, bytes]).subarray(-MAX_CAPTURE_BYTES);
163
+ },
164
+ get output() { return tail.toString("utf8"); },
165
+ get characters() { return characters; },
166
+ };
167
+ }
168
+
169
+ function terminateProcessGroup(child, signal) {
170
+ try { process.kill(-child.pid, signal); } catch { try { child.kill?.(signal); } catch { /* already exited */ } }
171
+ }
172
+
173
+ function waitForChild(child) {
174
+ return new Promise((resolve) => {
175
+ const close = (exitCode, signal) => { child.removeListener("error", failed); resolve({ exitCode, signal }); };
176
+ const failed = (error) => { child.removeListener("close", close); resolve({ error: error instanceof Error ? error : new Error(String(error)) }); };
177
+ child.once("close", close);
178
+ child.once("error", failed);
179
+ });
180
+ }
181
+
182
+ export async function cancelRun(runId, { call = callToolJson } = {}) {
183
+ const context = JSON.parse(await readFile(contextPath(runId), "utf8"));
184
+ if (!Number.isInteger(context.workload_pid) || context.workload_pid <= 0) {
185
+ return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", run_id: runId, error: "durable owner has not started a workload", exit_code: EXIT.INVALID } };
186
+ }
187
+ try {
188
+ process.kill(-context.workload_pid, "SIGTERM");
189
+ } catch (error) {
190
+ await call("update_command_run", { run_id: context.run_id, run_credential: context.run_credential, status: "owner_lost", invalidated_reason: `cancel_owner_unreachable:${error instanceof Error ? error.message : String(error)}` });
191
+ return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "owner_lost", run_id: runId, exit_code: EXIT.INTERNAL } };
192
+ }
193
+ return { exitCode: EXIT.OK, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "cancellation_requested", run_id: runId, exit_code: EXIT.OK } };
194
+ }
195
+
196
+ async function finalize(context, state, call) {
197
+ const boundedLog = String(state.output ?? "").slice(-8192);
198
+ const outputCharacters = state.outputCharacters ?? String(state.output ?? "").length;
199
+ const receipt = { schema_version: RUN_SCHEMA_VERSION, run_id: context.run_id, status: state.status, started_at: state.startedAt, ended_at: state.endedAt, exit_code: state.exitCode, signal: state.signal, recovery_command: state.status === "failure" ? `botbuddy run --session-id <new-session-id> --environment local -- ${context.command.map((x) => JSON.stringify(x)).join(" ")}` : null, bounded_log: boundedLog, output_characters: outputCharacters, reason: state.reason ?? null };
200
+ await writeJson(context.receipt_path, receipt);
201
+ const bytes = await stat(context.receipt_path);
202
+ const sha256 = createHash("sha256").update(await readFile(context.receipt_path)).digest("hex");
203
+ const apiStatus = ({ success: "succeeded", failure: "failed", cancelled: "cancelled", timed_out: "timed_out", owner_lost: "owner_lost" })[state.status];
204
+ const update = { run_id: context.run_id, run_credential: context.run_credential, status: apiStatus, exit_code: state.exitCode ?? undefined, invalidated_reason: state.reason ?? (state.status === "timed_out" ? "timeout" : state.status === "owner_lost" ? "owner_lost" : undefined), receipt_path: context.receipt_upload, receipt_content_type: "application/json", receipt_byte_size: bytes.size, receipt_sha256: sha256, output_characters: outputCharacters, truncated: outputCharacters > MAX_CAPTURE_BYTES };
205
+ await retryTerminalUpdate(context, update, call);
206
+ try { await unlink(context.context_path); } catch { /* receipt is authoritative; context cleanup is best effort */ }
207
+ return receipt;
208
+ }
209
+
210
+ export async function retryTerminalUpdate(context, update, call, { sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}) {
211
+ let attempts = 0;
212
+ let delay = 1_000;
213
+ for (;;) {
214
+ let result;
215
+ try { result = await call("update_command_run", update); } catch (error) { result = { ok: false, error: error instanceof Error ? error.message : String(error) }; }
216
+ if (result?.ok && !result.isError && result.data?.error == null && result.data?.accepted !== false) return result;
217
+ attempts += 1;
218
+ await writeJson(context.context_path, { ...context, terminal_update: { ...update, attempts, last_error: result?.error ?? result?.data?.error ?? "terminal_update_rejected", next_retry_at: new Date(Date.now() + delay).toISOString() } });
219
+ await sleep(delay);
220
+ delay = Math.min(delay * 2, 60_000);
221
+ }
222
+ }
223
+
224
+ export async function cmdRun(args) {
225
+ if (args[0] === "--worker") { await runWorker(args[1]); return; }
226
+ if (args[0] === "cancel") {
227
+ const result = await cancelRun(args[1] ?? "");
228
+ oneLine(result.receipt, false);
229
+ process.exitCode = result.exitCode;
230
+ return;
231
+ }
232
+ if (args[0] === "help" || args[0] === "--help") {
233
+ console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run cancel <run_id>");
234
+ return;
235
+ }
236
+ const result = await launchRun(args);
237
+ oneLine(result.receipt, Boolean(parseRunArgs(args).opts.json));
238
+ process.exitCode = result.exitCode;
239
+ }