@botbuddy/cli 1.62.5 → 1.63.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.
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/stack-forward-attestation.mjs +131 -0
- package/src/stack-tunnel.mjs +1 -0
- package/src/stack.mjs +36 -7
- package/src/wait-result.mjs +30 -8
- package/src/wait.mjs +2 -1
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.63.1",
|
|
3
|
+
"source_commit": "7f42fb391734dbc962548fddd42d4ccf84e63039",
|
|
4
|
+
"source_sha256": "7e21669993256b7566102111fe48ea9a2f7f5a309c962a99b82b4a2d27016d3d",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "a3d98f8e3d76fc7d30266fd5e261880615b5e8377e1211cf87fe3927dfb9f156"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.63.1",
|
|
3
|
+
"source_commit": "7f42fb391734dbc962548fddd42d4ccf84e63039",
|
|
4
|
+
"source_sha256": "7e21669993256b7566102111fe48ea9a2f7f5a309c962a99b82b4a2d27016d3d",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "1edca9db4f9e8e8d72b9112acf63ec6ba4ca09423cee8ef0afe4bae0abb78bc2"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.63.1",
|
|
4
|
+
"source_commit": "7f42fb391734dbc962548fddd42d4ccf84e63039",
|
|
5
|
+
"source_sha256": "7e21669993256b7566102111fe48ea9a2f7f5a309c962a99b82b4a2d27016d3d",
|
|
6
6
|
"targets": {
|
|
7
7
|
"darwin-arm64": {
|
|
8
8
|
"goos": "darwin",
|
|
9
9
|
"goarch": "arm64",
|
|
10
10
|
"binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "a3d98f8e3d76fc7d30266fd5e261880615b5e8377e1211cf87fe3927dfb9f156",
|
|
12
12
|
"mode": "0755"
|
|
13
13
|
},
|
|
14
14
|
"linux-x64": {
|
|
15
15
|
"goos": "linux",
|
|
16
16
|
"goarch": "amd64",
|
|
17
17
|
"binary": "daemon/bin/linux-x64/botbuddy-daemon",
|
|
18
|
-
"sha256": "
|
|
18
|
+
"sha256": "1edca9db4f9e8e8d72b9112acf63ec6ba4ca09423cee8ef0afe4bae0abb78bc2",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// BOT-2738: live transport evidence belongs to the process holding the tunnel,
|
|
2
|
+
// not a connection file which can outlive it. This private Unix socket is a
|
|
3
|
+
// local capability only; cmdStatus still authenticates the lease server-side.
|
|
4
|
+
import { createServer, get } from "node:http";
|
|
5
|
+
import { chmod, lstat, mkdtemp, rm } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
export const FORWARD_ATTESTATION_ENV = "BOTBUDDY_STACK_FORWARD_ATTESTATION_SOCKET";
|
|
11
|
+
const failure = () => new Error("live stack forward attestation unavailable");
|
|
12
|
+
|
|
13
|
+
export function ownsLsofSocket(output, ownerPid, socketPath, platform = process.platform) {
|
|
14
|
+
if (!["darwin", "linux"].includes(platform)) return false;
|
|
15
|
+
// Linux's native n field appends the socket type; Darwin's does not.
|
|
16
|
+
// Match the complete dialect-specific field, never trim arbitrary suffixes:
|
|
17
|
+
// a literal " type=STREAM" may itself be part of an untrusted pathname.
|
|
18
|
+
const expected = `n${socketPath}${platform === "linux" ? " type=STREAM" : ""}`;
|
|
19
|
+
let ownedProcess = false;
|
|
20
|
+
for (const field of String(output ?? "").split("\n")) {
|
|
21
|
+
if (field.startsWith("p")) ownedProcess = field === `p${ownerPid}`;
|
|
22
|
+
else if (ownedProcess && field === expected) return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A caller can replace an environment value or create its own socket. Require
|
|
28
|
+
// kernel evidence that this endpoint belongs to a live ancestor of the status
|
|
29
|
+
// process, rather than granting trust to the capability path or response alone.
|
|
30
|
+
function assertAncestorEndpoint(socketPath, ownerPid) {
|
|
31
|
+
if (!Number.isInteger(ownerPid) || ownerPid <= 1 || /[\r\n]/.test(socketPath)) throw failure();
|
|
32
|
+
let pid = process.ppid;
|
|
33
|
+
let found = false;
|
|
34
|
+
for (let depth = 0; depth < 32 && pid > 1; depth++) {
|
|
35
|
+
if (pid === ownerPid) { found = true; break; }
|
|
36
|
+
const parent = spawnSync("/bin/ps", ["-p", String(pid), "-o", "ppid="], { encoding: "utf8", timeout: 2000, maxBuffer: 1024 });
|
|
37
|
+
if (parent.status !== 0 || !/^\s*\d+\s*$/.test(parent.stdout)) throw failure();
|
|
38
|
+
const next = Number(parent.stdout.trim());
|
|
39
|
+
if (next === pid) throw failure();
|
|
40
|
+
pid = next;
|
|
41
|
+
}
|
|
42
|
+
if (!found) throw failure();
|
|
43
|
+
const lsof = process.platform === "darwin" ? "/usr/sbin/lsof" : "/usr/bin/lsof";
|
|
44
|
+
const held = spawnSync(lsof, ["-nP", "-a", "-U", "-p", String(ownerPid), "-F", "pn"], { encoding: "utf8", timeout: 2000, maxBuffer: 256 * 1024 });
|
|
45
|
+
if (held.status !== 0 || !ownsLsofSocket(held.stdout, ownerPid, socketPath)) throw failure();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validForwards(rows) {
|
|
49
|
+
return Array.isArray(rows) && rows.length > 0 && rows.length <= 32
|
|
50
|
+
&& rows.every((row) => typeof row?.name === "string" && row.name.length > 0 && row.name.length <= 64
|
|
51
|
+
&& [row.local, row.remote].every((p) => Number.isInteger(p) && p > 0 && p <= 65535))
|
|
52
|
+
&& ["name", "local", "remote"].every((key) => new Set(rows.map((r) => r[key])).size === rows.length);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function startForwardAttestation({ leaseId, sshTarget, forwards, isLive }) {
|
|
56
|
+
if (typeof leaseId !== "string" || !leaseId || typeof sshTarget !== "string" || !sshTarget
|
|
57
|
+
|| !validForwards(forwards) || typeof isLive !== "function" || isLive() !== true) throw failure();
|
|
58
|
+
const snapshot = {
|
|
59
|
+
schema_version: 1, lease_id: leaseId, ssh_target: sshTarget, owner_pid: process.pid,
|
|
60
|
+
forwards: forwards.map(({ name, local, remote }) => ({ name, local, remote })),
|
|
61
|
+
};
|
|
62
|
+
const root = await mkdtemp(join(tmpdir(), "bb-f-"));
|
|
63
|
+
const socketPath = join(root, "f");
|
|
64
|
+
const server = createServer((request, response) => {
|
|
65
|
+
let allowed = false;
|
|
66
|
+
try {
|
|
67
|
+
const url = new URL(request.url, "http://local");
|
|
68
|
+
allowed = request.method === "GET" && url.pathname === "/forwards"
|
|
69
|
+
&& url.searchParams.size === 1 && url.searchParams.get("lease_id") === leaseId && isLive() === true;
|
|
70
|
+
} catch { /* Unknown evidence is a refusal. */ }
|
|
71
|
+
response.writeHead(allowed ? 200 : 409, { "content-type": "application/json", "connection": "close" });
|
|
72
|
+
response.end(JSON.stringify(allowed ? snapshot : { error: "forward attestation unavailable" }));
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
await chmod(root, 0o700);
|
|
76
|
+
await new Promise((resolve, reject) => {
|
|
77
|
+
server.once("error", reject);
|
|
78
|
+
server.listen(socketPath, resolve);
|
|
79
|
+
});
|
|
80
|
+
await chmod(socketPath, 0o600);
|
|
81
|
+
server.unref();
|
|
82
|
+
} catch {
|
|
83
|
+
server.closeAllConnections();
|
|
84
|
+
server.close();
|
|
85
|
+
await rm(root, { recursive: true, force: true });
|
|
86
|
+
throw failure();
|
|
87
|
+
}
|
|
88
|
+
let closed;
|
|
89
|
+
return {
|
|
90
|
+
socketPath,
|
|
91
|
+
close() {
|
|
92
|
+
return closed ??= (async () => {
|
|
93
|
+
server.closeAllConnections();
|
|
94
|
+
await new Promise((resolve) => server.close(resolve));
|
|
95
|
+
await rm(root, { recursive: true, force: true });
|
|
96
|
+
})();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function readForwardAttestation({ socketPath, leaseId, sshTarget }) {
|
|
102
|
+
if (!socketPath) return null;
|
|
103
|
+
try {
|
|
104
|
+
const [socket, directory] = await Promise.all([lstat(socketPath), lstat(dirname(socketPath))]);
|
|
105
|
+
if (!socket.isSocket() || !directory.isDirectory()
|
|
106
|
+
|| socket.uid !== process.getuid() || directory.uid !== process.getuid()
|
|
107
|
+
|| (socket.mode & 0o777) !== 0o600 || (directory.mode & 0o777) !== 0o700) throw failure();
|
|
108
|
+
const payload = await new Promise((resolve, reject) => {
|
|
109
|
+
const request = get({ socketPath, path: `/forwards?lease_id=${encodeURIComponent(leaseId)}` }, (response) => {
|
|
110
|
+
if (response.statusCode !== 200) { response.resume(); reject(failure()); return; }
|
|
111
|
+
let body = "";
|
|
112
|
+
response.setEncoding("utf8");
|
|
113
|
+
response.on("data", (chunk) => {
|
|
114
|
+
body += chunk;
|
|
115
|
+
if (body.length > 16 * 1024) request.destroy(failure());
|
|
116
|
+
});
|
|
117
|
+
response.on("error", reject);
|
|
118
|
+
response.on("end", () => {
|
|
119
|
+
try { resolve(JSON.parse(body)); } catch { reject(failure()); }
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
const deadline = setTimeout(() => request.destroy(failure()), 2000);
|
|
123
|
+
request.on("close", () => clearTimeout(deadline));
|
|
124
|
+
request.on("error", reject);
|
|
125
|
+
});
|
|
126
|
+
if (payload?.schema_version !== 1 || payload.lease_id !== leaseId
|
|
127
|
+
|| payload.ssh_target !== sshTarget || !validForwards(payload.forwards)) throw failure();
|
|
128
|
+
assertAncestorEndpoint(socketPath, payload.owner_pid);
|
|
129
|
+
return payload.forwards;
|
|
130
|
+
} catch { throw failure(); }
|
|
131
|
+
}
|
package/src/stack-tunnel.mjs
CHANGED
|
@@ -286,6 +286,7 @@ export async function openSshForwards({
|
|
|
286
286
|
}
|
|
287
287
|
return {
|
|
288
288
|
pid: child.pid,
|
|
289
|
+
isLive: () => !exited && !closing,
|
|
289
290
|
argv,
|
|
290
291
|
exited: exitPromise,
|
|
291
292
|
onExit: (fn) => { listeners.push(fn); if (exited && !closing) fn({ ...exited, stderr: stderrText() }); },
|
package/src/stack.mjs
CHANGED
|
@@ -49,6 +49,7 @@ import { checkReadoptedStackMounts } from "./stack-mount-health.mjs";
|
|
|
49
49
|
import { machineUuid } from "./machine-id.mjs";
|
|
50
50
|
import { DEFAULT_BUNDLE_MAX_BYTES, buildStackBundle, uploadStackBundle, validateBundleIncludePath } from "./stack-bundle.mjs";
|
|
51
51
|
import { assertSshTarget, establishSshTunnel } from "./stack-tunnel.mjs";
|
|
52
|
+
import { FORWARD_ATTESTATION_ENV, startForwardAttestation, readForwardAttestation } from "./stack-forward-attestation.mjs";
|
|
52
53
|
import { ENGINE_PUBLISHES_BEYOND_LOOPBACK, engineSocketIsLocal, formatPublishIp, isLoopbackIp, publishIpFix, resolvePublishIp } from "./docker-publish-ip.mjs";
|
|
53
54
|
import { bold, dim, yellow } from "./utils.mjs";
|
|
54
55
|
import { reportStackRunOutcome } from "./stack-run-outcome.mjs";
|
|
@@ -108,7 +109,8 @@ export const STACK_HELP = `${bold("botbuddy stack")} — one command for a batch
|
|
|
108
109
|
|
|
109
110
|
${bold("USAGE")}
|
|
110
111
|
botbuddy stack up [options] Request a lease; park if the host is full; hold it active
|
|
111
|
-
botbuddy stack status <lease_id> Show a lease's state + connection
|
|
112
|
+
botbuddy stack status <lease_id> Show a lease's state + connection; within a live remote run,
|
|
113
|
+
include the owner's attested local/remote forwards
|
|
112
114
|
botbuddy stack touch <lease_id> Bump the idle clock (and report measured use) so the reaper doesn't stop it
|
|
113
115
|
botbuddy stack done <lease_id> Release (done): idle the stack for re-adoption (--reap tears it down)
|
|
114
116
|
botbuddy stack cancel <lease_id> Give a queued lease's place up (= done --reap; BOT-2400)
|
|
@@ -2960,6 +2962,7 @@ export async function cmdUp(opts, {
|
|
|
2960
2962
|
|
|
2961
2963
|
export async function cmdStatus(leaseId, opts, {
|
|
2962
2964
|
rpc = retryingStackCall(), emitResult = emit, stderr = (line) => process.stderr.write(`${line}\n`),
|
|
2965
|
+
forwardSocket = process.env[FORWARD_ATTESTATION_ENV],
|
|
2963
2966
|
} = {}) {
|
|
2964
2967
|
const auth = await rpc.init();
|
|
2965
2968
|
if (!auth) return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
@@ -2969,6 +2972,14 @@ export async function cmdStatus(leaseId, opts, {
|
|
|
2969
2972
|
: emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.BACKEND);
|
|
2970
2973
|
const g = got.data;
|
|
2971
2974
|
if (!g.success) return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, code: g.code, error: g.code }), opts, EXIT.BACKEND);
|
|
2975
|
+
let forwards;
|
|
2976
|
+
if (g.state === "active" && forwardSocket) {
|
|
2977
|
+
try {
|
|
2978
|
+
forwards = await readForwardAttestation({ socketPath: forwardSocket, leaseId, sshTarget: g.connection?.ssh_target ?? g.ssh_target });
|
|
2979
|
+
} catch {
|
|
2980
|
+
return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, code: "FORWARD_ATTESTATION_UNAVAILABLE", error: "live stack forward attestation unavailable" }), opts, EXIT.BACKEND);
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2972
2983
|
// BOT-2019: a queued lease names its server dispatch rank (or why it has none) and the
|
|
2973
2984
|
// ticket it is scored under; a null position is reported, never a crash.
|
|
2974
2985
|
const queued = g.state === "queued";
|
|
@@ -2976,7 +2987,9 @@ export async function cmdStatus(leaseId, opts, {
|
|
|
2976
2987
|
if (queued) for (const line of describeUnitLedger(g.ledger)) stderr(`stack: ${line}`);
|
|
2977
2988
|
return emitResult(buildReceipt({
|
|
2978
2989
|
command: "status", outcome: g.state, lease_id: leaseId, state: g.state,
|
|
2979
|
-
host_key: g.host_key, slot: g.slot,
|
|
2990
|
+
host_key: g.host_key, slot: g.slot,
|
|
2991
|
+
connection: forwards ? { ...g.connection, ssh_target: g.connection?.ssh_target ?? g.ssh_target } : g.connection,
|
|
2992
|
+
...(forwards ? { forwards } : {}),
|
|
2980
2993
|
...(queued ? queueReceiptFields(g) : { queue_position: g.queue_position }),
|
|
2981
2994
|
idle_ttl_secs: g.idle_ttl_secs, queued: g.queued,
|
|
2982
2995
|
// BOT-2099 (Codex #1057 R3): machine callers get the unit ledger of a queued lease too.
|
|
@@ -3621,6 +3634,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3621
3634
|
let activeWaitAbort = null;
|
|
3622
3635
|
// BOT-2142: the remote lease's forwards, and whether they died under a running child.
|
|
3623
3636
|
let tunnel = null;
|
|
3637
|
+
let forwardAttestation = null;
|
|
3624
3638
|
let tunnelLost = null;
|
|
3625
3639
|
const placement = { mode: null, hostKey: null, requesterHost: null, transport: "none", forwards: null, bundle: null, placementReason: null, considered: null };
|
|
3626
3640
|
incidentBox.execution = placement;
|
|
@@ -3633,10 +3647,19 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3633
3647
|
// BOT-1803: a heartbeat touch found the lease preempted under the running child.
|
|
3634
3648
|
let preempted = null;
|
|
3635
3649
|
const closeTunnel = async () => {
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3650
|
+
try {
|
|
3651
|
+
if (forwardAttestation) {
|
|
3652
|
+
const owner = forwardAttestation;
|
|
3653
|
+
forwardAttestation = null;
|
|
3654
|
+
await owner.close();
|
|
3655
|
+
}
|
|
3656
|
+
} finally {
|
|
3657
|
+
if (tunnel) {
|
|
3658
|
+
const open = tunnel;
|
|
3659
|
+
tunnel = null;
|
|
3660
|
+
try { await open.close(); } catch { /* best effort: the process is gone either way */ }
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3640
3663
|
};
|
|
3641
3664
|
|
|
3642
3665
|
// BOT-1803: `idle` (only the normal end of a run) asks the server to idle the ACTIVE lease
|
|
@@ -4352,13 +4375,17 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4352
4375
|
placement.transport = "ssh-forward";
|
|
4353
4376
|
placement.forwards = tunnel.forwards.map(({ name, remote, local }) => ({ name, remote, local }));
|
|
4354
4377
|
for (const f of tunnel.forwards.filter((x) => x.remapped)) stderrLine(`stack: forward ${f.name} remapped: local ${f.local} → ${placement.hostKey ?? "remote"} ${f.remote}`);
|
|
4355
|
-
childConnection = tunnel.connection;
|
|
4378
|
+
childConnection = { ...tunnel.connection, ssh_target: sshTarget };
|
|
4356
4379
|
const forwardTarget = sshTarget;
|
|
4357
4380
|
tunnel.onExit((exit) => {
|
|
4358
4381
|
if (childResult) return;
|
|
4359
4382
|
tunnelLost = { ...exit, target: forwardTarget };
|
|
4360
4383
|
stopChild("SIGTERM", EXIT.LEASE_FAILED);
|
|
4361
4384
|
});
|
|
4385
|
+
forwardAttestation = await startForwardAttestation({
|
|
4386
|
+
leaseId, sshTarget, forwards: placement.forwards,
|
|
4387
|
+
isLive: () => Boolean(tunnel && !tunnelLost && !receivedSignal && tunnel.isLive()),
|
|
4388
|
+
});
|
|
4362
4389
|
}
|
|
4363
4390
|
connectionFile = await writeConnection(safeConnectionPath(opts.connectionFile, leaseId), childConnection);
|
|
4364
4391
|
if (receivedSignal) {
|
|
@@ -4376,6 +4403,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4376
4403
|
...process.env,
|
|
4377
4404
|
BOTBUDDY_STACK_LEASE_ID: leaseId,
|
|
4378
4405
|
BOTBUDDY_STACK_CONNECTION_FILE: connectionFile,
|
|
4406
|
+
// A nested co-located run must not inherit its parent's capability.
|
|
4407
|
+
[FORWARD_ATTESTATION_ENV]: forwardAttestation?.socketPath ?? "",
|
|
4379
4408
|
// BOT-2142: lets a lane's own checks (e.g. the port-owner preflight) know the
|
|
4380
4409
|
// listeners are SSH forwards to another host, not this machine's Docker.
|
|
4381
4410
|
BOTBUDDY_STACK_EXECUTION_MODE: servedRemote ? "remote" : (placement.mode ?? "local"),
|
package/src/wait-result.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { WaitCheckpointError } from "./wait-checkpoint.mjs";
|
|
|
10
10
|
const MAX_ARTIFACT_BYTES = 256 * 1024;
|
|
11
11
|
const MAX_RECEIPT_BYTES = 64 * 1024;
|
|
12
12
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
|
+
const COMMAND_STATUSES = new Set(["success", "failure", "cancelled", "timed_out", "owner_lost"]);
|
|
13
14
|
const TERMINAL_OUTCOMES = new Set(["matched", "timeout", "error", "superseded", "lease_lost", "abandoned", "cancelled"]);
|
|
14
15
|
|
|
15
16
|
// Persisted != delivered. Await the stream callback before exiting, including
|
|
@@ -73,16 +74,37 @@ async function receiptFromArtifact(path) {
|
|
|
73
74
|
const buffer = Buffer.alloc(MAX_ARTIFACT_BYTES + 1);
|
|
74
75
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
75
76
|
if (bytesRead > MAX_ARTIFACT_BYTES) throw new Error("unbounded artifact");
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
const content = buffer.subarray(0, bytesRead).toString("utf8");
|
|
78
|
+
let values;
|
|
79
|
+
try { values = [JSON.parse(content)]; }
|
|
80
|
+
catch {
|
|
81
|
+
values = [];
|
|
82
|
+
for (const line of content.split(/\r?\n/)) {
|
|
83
|
+
if (!line.trimStart().startsWith("{")) continue;
|
|
84
|
+
// A partial JSON document alongside a receipt is ambiguous. In
|
|
85
|
+
// particular, never ignore a pretty command receipt and accept a later
|
|
86
|
+
// wait line from the same artifact.
|
|
87
|
+
values.push(JSON.parse(line));
|
|
88
|
+
}
|
|
82
89
|
}
|
|
90
|
+
const receipts = values.filter((value) => value?.schema_version === 1 &&
|
|
91
|
+
(typeof value.outcome === "string" || (typeof value.run_id === "string" && typeof value.status === "string")));
|
|
83
92
|
if (receipts.length !== 1) throw new Error("expected exactly one receipt");
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
const [receipt] = receipts;
|
|
94
|
+
if (!receipt.outcome && UUID.test(receipt.run_id) && COMMAND_STATUSES.has(receipt.status)) {
|
|
95
|
+
// A diagnostic, never evidence that this caller owns the command or that
|
|
96
|
+
// its workload passed. Only a validated UUID may enter recovery argv;
|
|
97
|
+
// never echo command text, logs, paths, or recovery_command from the file.
|
|
98
|
+
const recovery = `Run bb run inspect ${receipt.run_id} from the owning checkout; preserve the command receipt and read its bounded_log for the workload verdict. Do not rerun unchanged validation.`;
|
|
99
|
+
const error = new WaitCheckpointError("wait_result_wrong_artifact", `This is a command-run receipt, not a bb wait receipt. ${recovery}`);
|
|
100
|
+
error.recoveryArgv = ["run", "inspect", receipt.run_id];
|
|
101
|
+
error.recovery = recovery;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
if (typeof receipt.outcome !== "string") throw new Error("not a wait receipt");
|
|
105
|
+
return receipt;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof WaitCheckpointError) throw error;
|
|
86
108
|
// Never echo arbitrary artifact contents (which may include credentials).
|
|
87
109
|
throw new WaitCheckpointError("wait_observation_gap", "completion artifact must contain exactly one bounded terminal receipt");
|
|
88
110
|
} finally { await handle?.close(); }
|
package/src/wait.mjs
CHANGED
|
@@ -241,7 +241,8 @@ SUBCOMMANDS (saved waits live in this worktree's .botbuddy/waits/)
|
|
|
241
241
|
bb wait result [<local_wait_id>] [--from-file <path> --exit-code <n>]
|
|
242
242
|
read a terminal receipt without re-arming or acknowledging;
|
|
243
243
|
--from-file validates redirected output AFTER the original
|
|
244
|
-
command exits (see EXAMPLES)
|
|
244
|
+
wait command exits (see EXAMPLES). Command-run .receipt.json files
|
|
245
|
+
belong to bb run inspect <run_id>, not bb wait result.
|
|
245
246
|
bb wait resume <local_wait_id> recover one explicitly named saved wait
|
|
246
247
|
bb wait cancel <local_wait_id> explicitly stop one saved wait
|
|
247
248
|
bb wait acknowledge <local_wait_id> mark one saved terminal receipt read (after acting on it)
|