@botbuddy/cli 1.59.10 → 1.60.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/agent-doctor.mjs +23 -3
- package/src/commands.mjs +67 -5
- package/src/git-transport.mjs +476 -0
- package/src/run.mjs +165 -7
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.60.1",
|
|
3
|
+
"source_commit": "ac9d46a93d57bb9d5af94cba238532767c1f19cc",
|
|
4
|
+
"source_sha256": "667acf404a591bd0e09357645192040cba7a0b2085f33d89be6a4bbc84a6dd53",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "9dd4af84ad4131cf69c94bbed69f3c51087ca8ac4d742df6e9367faadbeddb97"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.60.1",
|
|
3
|
+
"source_commit": "ac9d46a93d57bb9d5af94cba238532767c1f19cc",
|
|
4
|
+
"source_sha256": "667acf404a591bd0e09357645192040cba7a0b2085f33d89be6a4bbc84a6dd53",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "61db8eb2108fcf9407460e96f487316728e262927241b5ee672ffca3443a11d3"
|
|
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.60.1",
|
|
4
|
+
"source_commit": "ac9d46a93d57bb9d5af94cba238532767c1f19cc",
|
|
5
|
+
"source_sha256": "667acf404a591bd0e09357645192040cba7a0b2085f33d89be6a4bbc84a6dd53",
|
|
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": "9dd4af84ad4131cf69c94bbed69f3c51087ca8ac4d742df6e9367faadbeddb97",
|
|
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": "61db8eb2108fcf9407460e96f487316728e262927241b5ee672ffca3443a11d3",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
package/src/agent-doctor.mjs
CHANGED
|
@@ -10,15 +10,35 @@ import { selfHealAgentSession } from "./agent-session.mjs";
|
|
|
10
10
|
import { machineUuid } from "./machine-id.mjs";
|
|
11
11
|
import { machineHost } from "./machine-host.mjs";
|
|
12
12
|
|
|
13
|
+
// BOT-2599: checks `--only` can run on their own. Each is local-only (no auth,
|
|
14
|
+
// no network), so it is fast enough for a git hook.
|
|
15
|
+
export const DOCTOR_ONLY_CHECKS = Object.freeze(["git_transport"]);
|
|
16
|
+
|
|
13
17
|
export function parseDoctorArgs(argv = []) {
|
|
14
18
|
const parsed = { fix: false, printExports: false, json: false, errors: [] };
|
|
15
|
-
for (
|
|
19
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
20
|
+
const arg = argv[i];
|
|
16
21
|
if (arg === "--fix") parsed.fix = true;
|
|
17
22
|
else if (arg === "--print-exports") parsed.printExports = true;
|
|
18
23
|
else if (arg === "--json") parsed.json = true;
|
|
19
24
|
else if (arg === "--help" || arg === "-h") parsed.help = true;
|
|
20
|
-
else
|
|
25
|
+
else if (arg === "--only" || arg.startsWith("--only=")) {
|
|
26
|
+
const value = arg === "--only" ? argv[++i] : arg.slice("--only=".length);
|
|
27
|
+
if (!value || value.startsWith("--")) parsed.errors.push("--only needs a check name (git_transport)");
|
|
28
|
+
else if (!DOCTOR_ONLY_CHECKS.includes(value)) parsed.errors.push(`unknown check: ${value} (--only accepts ${DOCTOR_ONLY_CHECKS.join(", ")})`);
|
|
29
|
+
else parsed.only = value;
|
|
30
|
+
} else if (arg === "--remote" || arg.startsWith("--remote=")) {
|
|
31
|
+
const value = arg === "--remote" ? argv[++i] : arg.slice("--remote=".length);
|
|
32
|
+
if (!value) parsed.errors.push("--remote needs a remote name or URL");
|
|
33
|
+
else parsed.remote = value;
|
|
34
|
+
} else if (arg === "--url" || arg.startsWith("--url=")) {
|
|
35
|
+
// A pre-push hook's $2: the URL this push actually goes to.
|
|
36
|
+
const value = arg === "--url" ? argv[++i] : arg.slice("--url=".length);
|
|
37
|
+
if (!value) parsed.errors.push("--url needs the push URL");
|
|
38
|
+
else parsed.url = value;
|
|
39
|
+
} else parsed.errors.push(`unknown option: ${arg}`);
|
|
21
40
|
}
|
|
41
|
+
if (parsed.only && parsed.printExports) parsed.errors.push("--print-exports cannot be combined with --only");
|
|
22
42
|
return parsed;
|
|
23
43
|
}
|
|
24
44
|
|
|
@@ -251,5 +271,5 @@ export async function doctorAgentAuth({
|
|
|
251
271
|
}
|
|
252
272
|
|
|
253
273
|
export function doctorHelp() {
|
|
254
|
-
return "bb doctor [--fix] [--print-exports] [--json]\n\nChecks the client key, repo binding, cached agent session,
|
|
274
|
+
return "bb doctor [--fix] [--print-exports] [--json]\nbb doctor --only git_transport [--remote <name>] [--url <push url>] [--fix] [--json]\n\nChecks the client key, repo binding, cached agent session, this worktree's stack-lease capability (registered host + attested machine), and whether git's SSH push connection carries a keepalive (git_transport). --fix mints a replacement session and (re)establishes the stack-lease capability; --print-exports prints its canonical shell exports. When the client key is unreachable (a Background LaunchAgent cannot read the login Keychain), --fix mints from the tier-2 MCP key exported under the binding's mcp_env name (env only, never Keychain).\n\ngit_transport: git opens the SSH connection BEFORE the pre-push hook runs, so a long hook lets it idle until it is dropped and the push dies (exit 141) after every gate passed. ok = ServerAliveInterval > 0 on the push's ssh; n/a = not an ssh remote; needs_fix / unknown = not provable. --fix sets git config --local core.sshCommand \"ssh -o ServerAliveInterval=20 -o ServerAliveCountMax=60\" only when nothing is configured; an existing GIT_SSH_COMMAND, core.sshCommand or GIT_SSH is never overwritten (the exact edit is printed instead).\n\n--only git_transport runs just that check — no sign-in, no network, well under a second — for a pre-push hook: exit 0 ok/n/a, 3 needs_fix/unknown (remedy on stderr), 4 usage. A hook passes its $1 as --remote and its $2 (the URL this push goes to) as --url; without --url every push URL of the remote (default: the branch's push remote, else origin) is checked.";
|
|
255
275
|
}
|
package/src/commands.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
|
|
|
30
30
|
import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation } from "./telemetry-config.mjs";
|
|
31
31
|
import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
|
|
32
32
|
import { doctorAgentAuth, doctorHelp, parseDoctorArgs, readClientKeyStrict, repairFailureRecovery } from "./agent-doctor.mjs";
|
|
33
|
+
import { GIT_TRANSPORT_PASSING, checkGitTransport, formatGitTransportLine } from "./git-transport.mjs";
|
|
33
34
|
import { formatPublishIp, isLoopbackIp, probeEnginePublishIp, publishIpFix } from "./docker-publish-ip.mjs";
|
|
34
35
|
import { cmdHost, fixHelperWorkers, formatHelperWorkersLines, inspectHelperWorkers } from "./helper-host.mjs";
|
|
35
36
|
import { clearAgentState, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
|
|
@@ -48,6 +49,9 @@ export function shouldCheckForUpdates(argv) {
|
|
|
48
49
|
// BOT-1982: nor `test-ingest`, which runs at the tail of every test harness.
|
|
49
50
|
if (command === "wait" || command === "telemetry" || command === "usage" || command === "test-ingest") return false;
|
|
50
51
|
if (argv.includes("--json")) return false;
|
|
52
|
+
// BOT-2599: `bb doctor --only <check>` runs inside a git pre-push hook; it must
|
|
53
|
+
// stay local and sub-second, so it never consults the npm registry.
|
|
54
|
+
if (command === "doctor" && argv.some((arg) => arg === "--only" || arg.startsWith("--only="))) return false;
|
|
51
55
|
return true;
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -284,10 +288,13 @@ export async function cmdDoctor(args, {
|
|
|
284
288
|
// BOT-1982: which credential test-run uploads would use — the ONE place a
|
|
285
289
|
// missing upload credential is reported (the harnesses skip silently).
|
|
286
290
|
testIngestReport = () => doctorTestIngest(),
|
|
291
|
+
// BOT-2599: will this clone's SSH push connection survive a long pre-push hook?
|
|
292
|
+
gitTransportReport = async ({ fix, remote, url }) => checkGitTransport({ fix, remote, url }),
|
|
287
293
|
} = {}) {
|
|
288
294
|
const parsed = parseDoctorArgs(args);
|
|
289
295
|
if (parsed.help) { log(doctorHelp()); return 0; }
|
|
290
296
|
if (parsed.errors.length) { errorLog(`bb doctor: ${parsed.errors.join("; ")}`); return 4; }
|
|
297
|
+
if (parsed.only === "git_transport") return doctorGitTransportOnly(parsed, { log, errorLog, gitTransportReport });
|
|
291
298
|
const result = await doctor({ fix: parsed.fix, printExports: parsed.printExports });
|
|
292
299
|
// BOT-2146: where would this host's engine publish a leased stack's ports?
|
|
293
300
|
// Informational (read-only, never changes the exit code); reported only when
|
|
@@ -310,8 +317,13 @@ export async function cmdDoctor(args, {
|
|
|
310
317
|
}
|
|
311
318
|
// BOT-1982: informational, like usage sync — never changes the exit code.
|
|
312
319
|
const testIngest = await Promise.resolve().then(testIngestReport).catch(() => null);
|
|
320
|
+
// BOT-2599: informational in the full report (its exit code stays the auth
|
|
321
|
+
// verdict existing callers key on); `--only git_transport` is the gating form.
|
|
322
|
+
const gitTransport = await Promise.resolve()
|
|
323
|
+
.then(() => gitTransportReport({ fix: parsed.fix, remote: parsed.remote ?? null, url: parsed.url ?? null }))
|
|
324
|
+
.catch(() => ({ check: "git_transport", status: "unknown", reason: "check_failed" }));
|
|
313
325
|
if (parsed.json) {
|
|
314
|
-
log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}), ...(helperWorkers ? { helper_workers: helperWorkers } : {}), ...(testIngest ? { test_ingest: testIngest } : {}) }));
|
|
326
|
+
log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}), ...(helperWorkers ? { helper_workers: helperWorkers } : {}), ...(testIngest ? { test_ingest: testIngest } : {}), ...(gitTransport ? { git_transport: gitTransport } : {}) }));
|
|
315
327
|
} else {
|
|
316
328
|
log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
|
|
317
329
|
log(`client key: ${result.report.client_key.status}`);
|
|
@@ -329,11 +341,36 @@ export async function cmdDoctor(args, {
|
|
|
329
341
|
if (usage) log(formatUsageSyncLine(usage));
|
|
330
342
|
for (const line of formatHelperWorkersLines(helperWorkers, helperFix)) log(line);
|
|
331
343
|
if (testIngest) log(formatTestIngestLine(testIngest));
|
|
344
|
+
if (gitTransport) {
|
|
345
|
+
log(formatGitTransportLine(gitTransport));
|
|
346
|
+
if (gitTransport.recovery) log(`git transport recovery: ${gitTransport.recovery}`);
|
|
347
|
+
}
|
|
332
348
|
}
|
|
333
349
|
if (result.exports) log(result.exports);
|
|
334
350
|
return result.exitCode;
|
|
335
351
|
}
|
|
336
352
|
|
|
353
|
+
/**
|
|
354
|
+
* BOT-2599: `bb doctor --only git_transport` — the one check, nothing else (no
|
|
355
|
+
* sign-in, no network), for a pre-push hook. Exit 0 = ok / n/a; 3 = needs_fix
|
|
356
|
+
* or unknown (a check that cannot prove a keepalive never passes); the remedy
|
|
357
|
+
* goes to stderr so a hook shows it.
|
|
358
|
+
*/
|
|
359
|
+
async function doctorGitTransportOnly(parsed, { log, errorLog, gitTransportReport }) {
|
|
360
|
+
let report;
|
|
361
|
+
try {
|
|
362
|
+
report = await gitTransportReport({ fix: parsed.fix, remote: parsed.remote ?? null, url: parsed.url ?? null });
|
|
363
|
+
} catch {
|
|
364
|
+
report = null;
|
|
365
|
+
}
|
|
366
|
+
if (!report) report = { check: "git_transport", status: "unknown", reason: "check_failed", recovery: "the git_transport check failed to run; run `bb doctor --only git_transport` by hand" };
|
|
367
|
+
if (parsed.json) log(JSON.stringify({ git_transport: report }));
|
|
368
|
+
else log(formatGitTransportLine(report));
|
|
369
|
+
const passing = GIT_TRANSPORT_PASSING.has(report.status);
|
|
370
|
+
if (!passing && report.recovery && !parsed.json) errorLog(`recovery: ${report.recovery}`);
|
|
371
|
+
return passing ? 0 : 3;
|
|
372
|
+
}
|
|
373
|
+
|
|
337
374
|
/** BOT-1021: is the daily sweep installed, and how did the last sync runs go? */
|
|
338
375
|
export async function doctorUsageSync({ agentStatus = usageAgentStatus, status = usageStatus } = {}) {
|
|
339
376
|
const agent = await agentStatus();
|
|
@@ -446,12 +483,24 @@ does not prove that the workload is still running.
|
|
|
446
483
|
|
|
447
484
|
${bold("Safe recovery")}
|
|
448
485
|
1. Preserve the command-run ID and the original result.
|
|
449
|
-
2.
|
|
450
|
-
3.
|
|
486
|
+
2. Run ${cyan("bb run inspect <command-run-id>")} from the owning checkout.
|
|
487
|
+
3. Prefer the owning runner's local terminal receipt for the workload verdict.
|
|
488
|
+
4. If the receipt is terminal, do not cancel or duplicate the command solely
|
|
451
489
|
because remote state remains ${cyan("registered")} or ${cyan("running")}.
|
|
452
|
-
|
|
490
|
+
5. If the receipt is absent/non-terminal, retain the original runner result;
|
|
453
491
|
do not poll or infer completion from elapsed time.
|
|
454
|
-
|
|
492
|
+
6. Report the receipt and remote discrepancy as telemetry evidence. A stale
|
|
493
|
+
test-run or command-run is never validation evidence.
|
|
494
|
+
|
|
495
|
+
${bold("One controlled push retry")}
|
|
496
|
+
Only after inspection leaves the original push unresolved, arm an exact-SHA CI
|
|
497
|
+
wait before one new push, then verify the resulting CI run reports that SHA:
|
|
498
|
+
|
|
499
|
+
${cyan("bb wait 'ci:repo=SupplySpark/supplyspark-ent,scope=next,sha=<full-sha>' --heartbeat")}
|
|
500
|
+
|
|
501
|
+
Do not substitute ${cyan("pr-state")}, a timer, or a status-polling loop for a bare
|
|
502
|
+
push. If the prior push has no terminal receipt, keep it unresolved rather than
|
|
503
|
+
counting it as validation evidence.
|
|
455
504
|
|
|
456
505
|
${bold("Boundary")}
|
|
457
506
|
Command-run status is agent-owned. A different agent may be denied access; that
|
|
@@ -675,6 +724,8 @@ export async function cmdSetup(args, {
|
|
|
675
724
|
selfHeal = selfHealAgentSession,
|
|
676
725
|
callerResolver = resolveCaller,
|
|
677
726
|
usageAgent = installUsageAgent,
|
|
727
|
+
// BOT-2599: setup also gives this clone's SSH push transport a keepalive.
|
|
728
|
+
gitTransport = async (opts) => checkGitTransport(opts),
|
|
678
729
|
cwd = process.cwd(),
|
|
679
730
|
env = process.env,
|
|
680
731
|
platform = process.platform,
|
|
@@ -786,6 +837,7 @@ export async function cmdSetup(args, {
|
|
|
786
837
|
errorLog(`bb setup: warning: ${warning} — held by ${who}. Confirm the active work before editing shared resources; typed locks remain required.`);
|
|
787
838
|
}
|
|
788
839
|
log(`bb setup: ${tenant} is ready (${session.source} worktree session). Run bb wait '<condition>'.`);
|
|
840
|
+
await reportGitTransport(gitTransport, { cwd, env, log });
|
|
789
841
|
await reportUsageAgent(usageAgent, { cwd, log });
|
|
790
842
|
return 0;
|
|
791
843
|
} catch (error) {
|
|
@@ -805,6 +857,16 @@ async function reportUsageAgent(usageAgent, { cwd, log }) {
|
|
|
805
857
|
else log(`bb setup: daily usage sync could not be installed (${result?.error ?? "unknown"}); run bb setup again to retry.`);
|
|
806
858
|
}
|
|
807
859
|
|
|
860
|
+
// BOT-2599: never fails setup — the session is ready either way; the line says
|
|
861
|
+
// whether pushes from this clone keep their SSH connection through a long hook.
|
|
862
|
+
async function reportGitTransport(gitTransport, { cwd, env, log }) {
|
|
863
|
+
let report;
|
|
864
|
+
try { report = await gitTransport({ cwd, env, fix: true }); } catch { report = null; }
|
|
865
|
+
if (!report) report = { check: "git_transport", status: "unknown", reason: "check_failed" };
|
|
866
|
+
log(`bb setup: ${formatGitTransportLine(report)}`);
|
|
867
|
+
if (report.recovery) log(`bb setup: git transport recovery: ${report.recovery}`);
|
|
868
|
+
}
|
|
869
|
+
|
|
808
870
|
export function loginHelp(log = console.log) {
|
|
809
871
|
log(`${bold("botbuddy login")} — authenticate via OAuth (browser + loopback callback)
|
|
810
872
|
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// BOT-2599 — keep git's SSH connection alive through a long pre-push hook.
|
|
2
|
+
//
|
|
3
|
+
// git opens the SSH transport and reads the remote's ref advertisement BEFORE it
|
|
4
|
+
// runs the pre-push hook (githooks(5)). A hook that runs for minutes leaves that
|
|
5
|
+
// connection idle until something on the path drops it, and the pack send then
|
|
6
|
+
// dies on a closed pipe (exit 141) after every gate passed. An SSH-level
|
|
7
|
+
// keepalive (`ServerAliveInterval` > 0) keeps it open.
|
|
8
|
+
//
|
|
9
|
+
// This module answers "will THIS clone's push connection carry a keepalive?" the
|
|
10
|
+
// way git itself picks the ssh command — GIT_SSH_COMMAND, then core.sshCommand,
|
|
11
|
+
// then GIT_SSH, then plain `ssh` — and, for ssh, the way ssh resolves options:
|
|
12
|
+
// an explicit `-o ServerAliveInterval=…` on the command line wins over every
|
|
13
|
+
// config file; otherwise `ssh -G` prints the effective value. `ssh -G` reads
|
|
14
|
+
// config only (no network), but a config can `Match exec`, so it is bounded and
|
|
15
|
+
// a timeout is a typed `unknown`, never a false `ok`.
|
|
16
|
+
//
|
|
17
|
+
// `--fix` writes `git config --local core.sshCommand` only when NOTHING is
|
|
18
|
+
// configured; an existing GIT_SSH_COMMAND, core.sshCommand or GIT_SSH is never
|
|
19
|
+
// overwritten — the report carries the exact edit instead. The remote URL is
|
|
20
|
+
// never reported (an HTTPS URL can embed a credential); only its host.
|
|
21
|
+
//
|
|
22
|
+
// Dependency-free (Node stdlib only); every git/ssh call goes through `run`
|
|
23
|
+
// (spawnSync-compatible) so the unit tests need no network and no real config.
|
|
24
|
+
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { basename } from "node:path";
|
|
28
|
+
|
|
29
|
+
export const GIT_TRANSPORT_CHECK = "git_transport";
|
|
30
|
+
export const KEEPALIVE_OPTIONS = "-o ServerAliveInterval=20 -o ServerAliveCountMax=60";
|
|
31
|
+
export const KEEPALIVE_SSH_COMMAND = `ssh ${KEEPALIVE_OPTIONS}`;
|
|
32
|
+
export const SSH_CONFIG_TIMEOUT_MS = 5_000;
|
|
33
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
34
|
+
|
|
35
|
+
/** Statuses that let a push proceed. `unknown` never does: it cannot prove a keepalive. */
|
|
36
|
+
export const GIT_TRANSPORT_PASSING = Object.freeze(new Set(["ok", "n/a"]));
|
|
37
|
+
|
|
38
|
+
function shellQuote(value) {
|
|
39
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Classify a remote URL by transport. Only ssh remotes carry host/user/port;
|
|
44
|
+
* nothing else of the URL is kept (it may embed a credential).
|
|
45
|
+
*/
|
|
46
|
+
export function classifyRemoteUrl(url) {
|
|
47
|
+
const value = String(url ?? "").trim();
|
|
48
|
+
if (!value) return { transport: "none" };
|
|
49
|
+
const scheme = value.match(/^([a-z][a-z0-9+.-]*):\/\//i);
|
|
50
|
+
if (scheme) {
|
|
51
|
+
const name = scheme[1].toLowerCase();
|
|
52
|
+
if (name === "ssh" || name === "git+ssh" || name === "ssh+git") {
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = new URL(`ssh://${value.slice(scheme[0].length)}`);
|
|
56
|
+
} catch {
|
|
57
|
+
return { transport: "ssh", host: null, user: null, port: null };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
transport: "ssh",
|
|
61
|
+
host: parsed.hostname.replace(/^\[|\]$/g, "") || null,
|
|
62
|
+
user: parsed.username ? decodeURIComponent(parsed.username) : null,
|
|
63
|
+
port: parsed.port || null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { transport: name === "https" || name === "http" ? "https" : name };
|
|
67
|
+
}
|
|
68
|
+
// git's scp-like syntax, `[user@]host:path`, applies only when no slash comes
|
|
69
|
+
// before the first colon; anything else is a local path.
|
|
70
|
+
const scp = value.match(/^(?:([^@/:]+)@)?(\[[^\]/]+\]|[^:/]+):/);
|
|
71
|
+
if (scp) return { transport: "ssh", host: scp[2].replace(/^\[|\]$/g, ""), user: scp[1] ?? null, port: null };
|
|
72
|
+
return { transport: "local" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Split a git ssh command the way `sh -c` evaluates it (git runs
|
|
77
|
+
* core.sshCommand / GIT_SSH_COMMAND through the shell): words, quotes,
|
|
78
|
+
* backslash escapes, a leading `~` / `~/` (the home directory) and plain
|
|
79
|
+
* `$NAME` / `${NAME}` expansions from `env`. Returns null for anything a real
|
|
80
|
+
* shell would do that this cannot reproduce faithfully — command substitution,
|
|
81
|
+
* other parameter forms, `~user`, pipes, redirects, a leading comment, an
|
|
82
|
+
* unbalanced quote, or an unquoted expansion that word splitting or globbing
|
|
83
|
+
* would change — so the caller reports `unknown` instead of guessing.
|
|
84
|
+
*/
|
|
85
|
+
export function splitShellWords(text, env = {}) {
|
|
86
|
+
const words = [];
|
|
87
|
+
let current = "";
|
|
88
|
+
let inWord = false;
|
|
89
|
+
let quote = null;
|
|
90
|
+
const home = () => env.HOME || homedir() || null;
|
|
91
|
+
// `$NAME` / `${NAME}` at text[i] ("$"); returns { value, next } or null.
|
|
92
|
+
const expand = (i) => {
|
|
93
|
+
const braced = text[i + 1] === "{";
|
|
94
|
+
const m = text.slice(braced ? i + 2 : i + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
95
|
+
if (!m) return null;
|
|
96
|
+
let next = i + 1 + (braced ? 1 : 0) + m[0].length;
|
|
97
|
+
if (braced) {
|
|
98
|
+
if (text[next] !== "}") return null;
|
|
99
|
+
next += 1;
|
|
100
|
+
}
|
|
101
|
+
return { value: env[m[0]] ?? "", next: next - 1 };
|
|
102
|
+
};
|
|
103
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
104
|
+
const ch = text[i];
|
|
105
|
+
if (quote === "'") {
|
|
106
|
+
if (ch === "'") quote = null;
|
|
107
|
+
else current += ch;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (quote === '"') {
|
|
111
|
+
if (ch === '"') quote = null;
|
|
112
|
+
else if (ch === "\\" && i + 1 < text.length && '"\\$`'.includes(text[i + 1])) current += text[++i];
|
|
113
|
+
else if (ch === "`") return null;
|
|
114
|
+
else if (ch === "$") {
|
|
115
|
+
const ex = expand(i);
|
|
116
|
+
if (!ex) return null;
|
|
117
|
+
current += ex.value;
|
|
118
|
+
i = ex.next;
|
|
119
|
+
} else current += ch;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (ch === "'" || ch === '"') {
|
|
123
|
+
quote = ch;
|
|
124
|
+
inWord = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (ch === "\\") {
|
|
128
|
+
if (i + 1 >= text.length) return null;
|
|
129
|
+
current += text[++i];
|
|
130
|
+
inWord = true;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (/\s/.test(ch)) {
|
|
134
|
+
if (inWord) words.push(current);
|
|
135
|
+
current = "";
|
|
136
|
+
inWord = false;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (ch === "~" && !inWord) {
|
|
140
|
+
// Tilde-prefix: only a bare `~` ending at `/`, whitespace or the end.
|
|
141
|
+
const after = text[i + 1];
|
|
142
|
+
if (after !== undefined && after !== "/" && !/\s/.test(after)) return null;
|
|
143
|
+
const dir = home();
|
|
144
|
+
if (!dir) return null;
|
|
145
|
+
current += dir;
|
|
146
|
+
inWord = true;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (ch === "$") {
|
|
150
|
+
const ex = expand(i);
|
|
151
|
+
if (!ex || /[\s*?[]/.test(ex.value)) return null;
|
|
152
|
+
current += ex.value;
|
|
153
|
+
if (ex.value) inWord = true;
|
|
154
|
+
i = ex.next;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if ("|&;<>()`".includes(ch) || (ch === "#" && !inWord)) return null;
|
|
158
|
+
current += ch;
|
|
159
|
+
inWord = true;
|
|
160
|
+
}
|
|
161
|
+
if (quote) return null;
|
|
162
|
+
if (inWord) words.push(current);
|
|
163
|
+
return words;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** ssh's time format (`20`, `30s`, `1m`, `1h30m`) in seconds, or null. */
|
|
167
|
+
export function parseSshTime(value) {
|
|
168
|
+
const text = String(value ?? "").trim().toLowerCase();
|
|
169
|
+
if (/^\d+$/.test(text)) return Number(text);
|
|
170
|
+
const units = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
|
|
171
|
+
let total = 0;
|
|
172
|
+
let rest = text;
|
|
173
|
+
if (!rest) return null;
|
|
174
|
+
while (rest) {
|
|
175
|
+
const m = rest.match(/^(\d+)([smhdw])/);
|
|
176
|
+
if (!m) return null;
|
|
177
|
+
total += Number(m[1]) * units[m[2]];
|
|
178
|
+
rest = rest.slice(m[0].length);
|
|
179
|
+
}
|
|
180
|
+
return total;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ssh(1) options that take an argument (`ssh -h` usage line).
|
|
184
|
+
const SSH_ARG_FLAGS = new Set("BbcDEeFIiJLlmOoPpQRSWw");
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Parse ssh's own argument list (everything after the program) the way ssh's
|
|
188
|
+
* getopt does: `-o` values, and any positional word. A positional word in a
|
|
189
|
+
* configured ssh command is an embedded destination (`ssh bastion ssh …`): git
|
|
190
|
+
* appends its host and remote command AFTER it, so a probe that appended `-G`
|
|
191
|
+
* would become a remote command and connect. Returns null when an option is
|
|
192
|
+
* missing its argument.
|
|
193
|
+
*/
|
|
194
|
+
export function parseSshArgs(args) {
|
|
195
|
+
const options = [];
|
|
196
|
+
const positional = [];
|
|
197
|
+
// The arguments up to (not including) a `--` terminator: a probe must put its
|
|
198
|
+
// own options before the terminator, or ssh reads `-G` as the destination.
|
|
199
|
+
let optionArgs = args;
|
|
200
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
201
|
+
const arg = args[i];
|
|
202
|
+
if (arg === "--") {
|
|
203
|
+
optionArgs = args.slice(0, i);
|
|
204
|
+
positional.push(...args.slice(i + 1));
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
if (!arg.startsWith("-") || arg === "-") {
|
|
208
|
+
positional.push(arg);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
for (let j = 1; j < arg.length; j += 1) {
|
|
212
|
+
const flag = arg[j];
|
|
213
|
+
if (!SSH_ARG_FLAGS.has(flag)) continue;
|
|
214
|
+
let value = arg.slice(j + 1);
|
|
215
|
+
if (!value) {
|
|
216
|
+
if (i + 1 >= args.length) return null;
|
|
217
|
+
value = args[++i];
|
|
218
|
+
}
|
|
219
|
+
if (flag === "o") options.push(value);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { options, positional, optionArgs };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The ServerAliveInterval an ssh argument list sets explicitly, or null. ssh
|
|
228
|
+
* keeps the FIRST value it obtains and the command line beats every config
|
|
229
|
+
* file, so the first explicit `-o ServerAliveInterval` is authoritative.
|
|
230
|
+
*/
|
|
231
|
+
export function explicitServerAliveInterval(args) {
|
|
232
|
+
const parsed = parseSshArgs(args);
|
|
233
|
+
for (const option of parsed?.options ?? []) {
|
|
234
|
+
const m = option.match(/^\s*serveraliveinterval\s*(?:=\s*|\s+)(\S+)\s*$/i);
|
|
235
|
+
if (m) return parseSshTime(m[1]);
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Insert the keepalive options right after the program, so they win over any later `-o`. */
|
|
241
|
+
export function withKeepalive(command) {
|
|
242
|
+
const m = String(command).match(/^(\s*)(\S+)/);
|
|
243
|
+
if (!m) return KEEPALIVE_SSH_COMMAND;
|
|
244
|
+
return `${m[1]}${m[2]} ${KEEPALIVE_OPTIONS}${command.slice(m[0].length)}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function runQuiet(run, cmd, args, { cwd, env, timeout }) {
|
|
248
|
+
let res;
|
|
249
|
+
try {
|
|
250
|
+
res = run(cmd, args, { cwd, env, encoding: "utf8", timeout, stdio: ["ignore", "pipe", "pipe"] });
|
|
251
|
+
} catch (error) {
|
|
252
|
+
return { status: null, stdout: "", stderr: "", error };
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
status: typeof res?.status === "number" ? res.status : null,
|
|
256
|
+
stdout: String(res?.stdout ?? ""),
|
|
257
|
+
stderr: String(res?.stderr ?? ""),
|
|
258
|
+
error: res?.error ?? null,
|
|
259
|
+
signal: res?.signal ?? null,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function git(run, args, { cwd, env }) {
|
|
264
|
+
return runQuiet(run, "git", args, { cwd, env, timeout: GIT_TIMEOUT_MS });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function gitConfigValue(run, key, ctx) {
|
|
268
|
+
const res = git(run, ["config", "--get", key], ctx);
|
|
269
|
+
return res.status === 0 ? res.stdout.replace(/\n$/, "") : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Which remote this push would use: branch pushRemote → remote.pushDefault → branch remote → origin. */
|
|
273
|
+
function defaultPushRemote(run, ctx) {
|
|
274
|
+
const head = git(run, ["symbolic-ref", "--quiet", "--short", "HEAD"], ctx);
|
|
275
|
+
const branch = head.status === 0 ? head.stdout.trim() : null;
|
|
276
|
+
return (branch && gitConfigValue(run, `branch.${branch}.pushRemote`, ctx))
|
|
277
|
+
|| gitConfigValue(run, "remote.pushDefault", ctx)
|
|
278
|
+
|| (branch && gitConfigValue(run, `branch.${branch}.remote`, ctx))
|
|
279
|
+
|| "origin";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Every URL this push may go to. A hook passes the actual destination (`$2`) as
|
|
284
|
+
* `url`; otherwise all of the remote's push URLs (`--all`: a remote can push to
|
|
285
|
+
* several) so the verdict covers each, not just the first.
|
|
286
|
+
*/
|
|
287
|
+
function resolveDestinations(run, { remote, url }, ctx) {
|
|
288
|
+
// A remote given as a URL (a push to a URL) is never echoed: it can embed a credential.
|
|
289
|
+
if (url) return { name: remote && classifyRemoteUrl(remote).transport === "local" ? remote : null, urls: [url] };
|
|
290
|
+
const name = remote || defaultPushRemote(run, ctx);
|
|
291
|
+
const res = git(run, ["remote", "get-url", "--push", "--all", name], ctx);
|
|
292
|
+
const urls = res.status === 0 ? res.stdout.split("\n").map((line) => line.trim()).filter(Boolean) : [];
|
|
293
|
+
if (urls.length) return { name, urls };
|
|
294
|
+
// A pre-push hook's $1 is the URL itself when the push named a URL, not a remote.
|
|
295
|
+
if (remote && classifyRemoteUrl(remote).transport !== "local") return { name: null, urls: [remote] };
|
|
296
|
+
return { name, urls: [], error: res.error ? "git_unavailable" : "no_push_remote" };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** git's own order: GIT_SSH_COMMAND, core.sshCommand, GIT_SSH, then plain `ssh`. */
|
|
300
|
+
function resolveSshCommand(run, env, ctx) {
|
|
301
|
+
if (env.GIT_SSH_COMMAND) return { source: "GIT_SSH_COMMAND", command: env.GIT_SSH_COMMAND, words: splitShellWords(env.GIT_SSH_COMMAND, env) };
|
|
302
|
+
let res = git(run, ["config", "--show-scope", "--get", "core.sshCommand"], ctx);
|
|
303
|
+
if (res.status === 129) res = git(run, ["config", "--get", "core.sshCommand"], ctx); // git < 2.26
|
|
304
|
+
if (res.status === 0) {
|
|
305
|
+
const line = res.stdout.replace(/\n$/, "");
|
|
306
|
+
const tab = line.indexOf("\t");
|
|
307
|
+
const scoped = tab > 0 && /^(local|global|system|worktree|command)$/.test(line.slice(0, tab));
|
|
308
|
+
const command = scoped ? line.slice(tab + 1) : line;
|
|
309
|
+
return { source: "core.sshCommand", scope: scoped ? line.slice(0, tab) : null, command, words: splitShellWords(command, env) };
|
|
310
|
+
}
|
|
311
|
+
if (env.GIT_SSH) return { source: "GIT_SSH", command: env.GIT_SSH, words: [env.GIT_SSH] };
|
|
312
|
+
return { source: "ssh_config", command: "ssh", words: ["ssh"] };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function sshEffectiveInterval(run, optionArgs, program, remote, { env, timeout }) {
|
|
316
|
+
// The configured options, then the probe's, then `--` and the host: a host can
|
|
317
|
+
// never be read as an option, and a configured trailing `--` cannot swallow -G.
|
|
318
|
+
const args = [...optionArgs, "-G"];
|
|
319
|
+
if (remote.port) args.push("-p", remote.port);
|
|
320
|
+
if (remote.user) args.push("-l", remote.user);
|
|
321
|
+
args.push("--", remote.host);
|
|
322
|
+
const res = runQuiet(run, program, args, { env, timeout });
|
|
323
|
+
if (res.error?.code === "ETIMEDOUT" || (res.signal && res.status === null)) return { reason: "ssh_config_timeout" };
|
|
324
|
+
if (res.error?.code === "ENOENT") return { reason: "ssh_not_found" };
|
|
325
|
+
if (res.error || res.status !== 0) return { reason: "ssh_config_unreadable" };
|
|
326
|
+
const m = res.stdout.match(/^serveraliveinterval\s+(\S+)\s*$/im);
|
|
327
|
+
const interval = m ? parseSshTime(m[1]) : null;
|
|
328
|
+
return interval == null ? { reason: "ssh_config_unparseable" } : { interval };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function recoveryFor(report, resolved) {
|
|
332
|
+
if (report.status === "ok" || report.status === "n/a") return null;
|
|
333
|
+
if (resolved.source === "ssh_config") {
|
|
334
|
+
if (report.status === "needs_fix") {
|
|
335
|
+
return `nothing gives this push a keepalive (ssh config for ${report.host}: ServerAliveInterval ${report.server_alive_interval}); run \`bb doctor --fix\` — it sets git config --local core.sshCommand "${KEEPALIVE_SSH_COMMAND}" for this clone and its worktrees`;
|
|
336
|
+
}
|
|
337
|
+
return `\`ssh -G ${report.host}\` gave no answer (${report.reason}), so a keepalive cannot be proven; run \`bb doctor --fix\` to set an explicit one in core.sshCommand`;
|
|
338
|
+
}
|
|
339
|
+
if (report.reason === "ssh_command_has_destination") {
|
|
340
|
+
return `the ssh command embeds a destination (a nested connection such as \`ssh bastion ssh\`), so its keepalive cannot be proven without connecting and \`bb doctor --fix\` never edits it; use ProxyJump instead: ssh -J <bastion> ${KEEPALIVE_OPTIONS}`;
|
|
341
|
+
}
|
|
342
|
+
// Echo the configured command back only when it is plain ssh: another program
|
|
343
|
+
// (sshpass, a wrapper) can carry a secret on its command line.
|
|
344
|
+
const plainSsh = Boolean(resolved.words?.length) && basename(resolved.words[0]) === "ssh";
|
|
345
|
+
const suggested = plainSsh ? withKeepalive(resolved.command) : null;
|
|
346
|
+
const edit = suggested
|
|
347
|
+
? `add ${KEEPALIVE_OPTIONS} right after the program`
|
|
348
|
+
: `make its keepalive explicit with ${KEEPALIVE_OPTIONS}`;
|
|
349
|
+
if (resolved.source === "GIT_SSH_COMMAND") {
|
|
350
|
+
return `GIT_SSH_COMMAND overrides git config and \`bb doctor --fix\` never edits it; ${edit}${suggested ? `: export GIT_SSH_COMMAND=${shellQuote(suggested)}` : ""} (or unset it and run \`bb doctor --fix\`)`;
|
|
351
|
+
}
|
|
352
|
+
if (resolved.source === "GIT_SSH") {
|
|
353
|
+
return `GIT_SSH is set and \`bb doctor --fix\` never overrides it; export GIT_SSH_COMMAND=${shellQuote(`${resolved.command} ${KEEPALIVE_OPTIONS}`)} or unset GIT_SSH and run \`bb doctor --fix\``;
|
|
354
|
+
}
|
|
355
|
+
const where = resolved.scope && resolved.scope !== "command" ? ` --${resolved.scope}` : "";
|
|
356
|
+
const how = resolved.scope === "command" ? "it is passed with `git -c`; change it there" : null;
|
|
357
|
+
return `core.sshCommand${resolved.scope ? ` (${resolved.scope} config)` : ""} has no keepalive and \`bb doctor --fix\` never overwrites it; ${edit}${how ? ` — ${how}` : suggested ? `: git config${where} core.sshCommand ${shellQuote(suggested)}` : ""}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The verdict for one push URL. `sshCommand()` resolves the ssh command once, on first need. */
|
|
361
|
+
function evaluateDestination(rawUrl, { run, env, sshTimeoutMs, sshCommand }) {
|
|
362
|
+
const url = classifyRemoteUrl(rawUrl);
|
|
363
|
+
if (url.transport !== "ssh") return { status: "n/a", transport: url.transport, reason: `${url.transport}_remote` };
|
|
364
|
+
if (!url.host) return { status: "unknown", transport: "ssh", host: null, reason: "unparseable_remote_url" };
|
|
365
|
+
// git refuses a host or user that starts with "-" (it would be read as an ssh
|
|
366
|
+
// option, CVE-2017-1000117); so does this probe, without running ssh.
|
|
367
|
+
if (url.host.startsWith("-") || url.user?.startsWith("-")) return { status: "unknown", transport: "ssh", host: null, reason: "unsafe_remote_url" };
|
|
368
|
+
const resolved = sshCommand();
|
|
369
|
+
const report = {
|
|
370
|
+
transport: "ssh", host: url.host, source: resolved.source,
|
|
371
|
+
...(resolved.scope ? { scope: resolved.scope } : {}),
|
|
372
|
+
server_alive_interval: null,
|
|
373
|
+
};
|
|
374
|
+
if (!resolved.words || resolved.words.length === 0) {
|
|
375
|
+
report.reason = "unparseable_ssh_command";
|
|
376
|
+
} else {
|
|
377
|
+
const isSsh = basename(resolved.words[0]) === "ssh";
|
|
378
|
+
const sshArgs = isSsh ? parseSshArgs(resolved.words.slice(1)) : null;
|
|
379
|
+
const explicit = explicitServerAliveInterval(resolved.words.slice(1));
|
|
380
|
+
if (isSsh && (!sshArgs || sshArgs.positional.length > 0)) {
|
|
381
|
+
// `ssh bastion ssh`: a nested connection. Never probe it (the probe would
|
|
382
|
+
// connect); its inner keepalive cannot be proven from here either.
|
|
383
|
+
report.reason = sshArgs ? "ssh_command_has_destination" : "unparseable_ssh_command";
|
|
384
|
+
} else if (explicit != null) {
|
|
385
|
+
report.server_alive_interval = explicit;
|
|
386
|
+
report.interval_origin = "command_line";
|
|
387
|
+
} else if (isSsh) {
|
|
388
|
+
const effective = sshEffectiveInterval(run, sshArgs.optionArgs, resolved.words[0], url, { env, timeout: sshTimeoutMs });
|
|
389
|
+
if (effective.reason) report.reason = effective.reason;
|
|
390
|
+
else {
|
|
391
|
+
report.server_alive_interval = effective.interval;
|
|
392
|
+
report.interval_origin = "ssh_config";
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
report.reason = "custom_ssh_program";
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (report.server_alive_interval == null) report.status = "unknown";
|
|
399
|
+
else report.status = report.server_alive_interval > 0 ? "ok" : "needs_fix";
|
|
400
|
+
return report;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function evaluate({ cwd, env, remote, url, run, sshTimeoutMs }) {
|
|
404
|
+
const ctx = { cwd, env };
|
|
405
|
+
const target = resolveDestinations(run, { remote, url }, ctx);
|
|
406
|
+
const base = { check: GIT_TRANSPORT_CHECK, remote: target.name ?? null };
|
|
407
|
+
if (target.urls.length === 0) {
|
|
408
|
+
if (target.error === "git_unavailable") return { report: { ...base, status: "unknown", transport: null, reason: "git_unavailable", recovery: "git could not run here (not on PATH, or the directory is missing); run the check from the clone you push from" } };
|
|
409
|
+
return { report: { ...base, status: "n/a", transport: "none", reason: "no_push_remote" } };
|
|
410
|
+
}
|
|
411
|
+
let resolved = null;
|
|
412
|
+
const sshCommand = () => (resolved ??= resolveSshCommand(run, env, ctx));
|
|
413
|
+
const results = target.urls.map((u) => evaluateDestination(u, { run, env, sshTimeoutMs, sshCommand }));
|
|
414
|
+
// The push passes only if every destination does: report the first that does not.
|
|
415
|
+
const primary = results.find((r) => !GIT_TRANSPORT_PASSING.has(r.status))
|
|
416
|
+
?? results.find((r) => r.transport === "ssh") ?? results[0];
|
|
417
|
+
const report = { ...base, ...primary };
|
|
418
|
+
if (results.length > 1) {
|
|
419
|
+
report.destinations = results.map((r) => ({
|
|
420
|
+
transport: r.transport, ...(r.host ? { host: r.host } : {}), status: r.status,
|
|
421
|
+
...(r.server_alive_interval != null ? { server_alive_interval: r.server_alive_interval } : {}),
|
|
422
|
+
...(r.reason ? { reason: r.reason } : {}),
|
|
423
|
+
}));
|
|
424
|
+
}
|
|
425
|
+
if (report.reason === "unparseable_remote_url" || report.reason === "unsafe_remote_url") report.recovery = "a push URL's host cannot be parsed safely; check `git remote get-url --push --all`";
|
|
426
|
+
else if (resolved) {
|
|
427
|
+
const recovery = recoveryFor(report, resolved);
|
|
428
|
+
if (recovery) report.recovery = recovery;
|
|
429
|
+
}
|
|
430
|
+
return { report, resolved };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Check (and with `fix`, repair) the keepalive on this clone's push transport.
|
|
435
|
+
* `url` is the push's actual destination (a pre-push hook's `$2`); without it
|
|
436
|
+
* every push URL of `remote` (default: the branch's push remote) is checked.
|
|
437
|
+
* Returns the report: { check, status: ok|needs_fix|n/a|unknown, remote,
|
|
438
|
+
* transport, host?, source?, scope?, server_alive_interval?, interval_origin?,
|
|
439
|
+
* reason?, recovery?, destinations? (when several URLs), fix? }.
|
|
440
|
+
*/
|
|
441
|
+
export function checkGitTransport({
|
|
442
|
+
cwd = process.cwd(), env = process.env, remote = null, url = null, fix = false,
|
|
443
|
+
run = spawnSync, sshTimeoutMs = SSH_CONFIG_TIMEOUT_MS,
|
|
444
|
+
} = {}) {
|
|
445
|
+
const first = evaluate({ cwd, env, remote, url, run, sshTimeoutMs });
|
|
446
|
+
if (!fix || GIT_TRANSPORT_PASSING.has(first.report.status) || !first.resolved) return first.report;
|
|
447
|
+
if (first.resolved.source !== "ssh_config") {
|
|
448
|
+
return { ...first.report, fix: { applied: false, reason: `existing_${first.resolved.source}_left_untouched` } };
|
|
449
|
+
}
|
|
450
|
+
const set = git(run, ["config", "--local", "core.sshCommand", KEEPALIVE_SSH_COMMAND], { cwd, env });
|
|
451
|
+
if (set.status !== 0) {
|
|
452
|
+
return { ...first.report, fix: { applied: false, reason: "git_config_failed" } };
|
|
453
|
+
}
|
|
454
|
+
// Report fixed only on proof: re-resolve exactly as a push would.
|
|
455
|
+
const after = evaluate({ cwd, env, remote, url, run, sshTimeoutMs }).report;
|
|
456
|
+
return { ...after, fix: { applied: true, set: `git config --local core.sshCommand "${KEEPALIVE_SSH_COMMAND}"`, verified: after.status === "ok" } };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** One human line for `bb doctor` / `bb setup`. */
|
|
460
|
+
export function formatGitTransportLine(report) {
|
|
461
|
+
if (!report) return "git transport: unknown (check failed)";
|
|
462
|
+
const parts = [];
|
|
463
|
+
if (report.host) parts.push(`ssh ${report.host}`);
|
|
464
|
+
else if (report.transport && report.transport !== "none" && report.transport !== "ssh") parts.push(`${report.transport} remote`);
|
|
465
|
+
if (report.server_alive_interval != null) {
|
|
466
|
+
const via = report.interval_origin === "command_line" ? report.source
|
|
467
|
+
: report.source === "ssh_config" ? "ssh config" : `${report.source} → ssh config`;
|
|
468
|
+
parts.push(`ServerAliveInterval ${report.server_alive_interval} via ${via}`);
|
|
469
|
+
} else if (report.reason && !(report.transport && report.reason === `${report.transport}_remote`)) {
|
|
470
|
+
parts.push(report.reason);
|
|
471
|
+
}
|
|
472
|
+
const fixed = report.fix?.applied
|
|
473
|
+
? (report.fix.verified ? " — fixed: set core.sshCommand (local)" : " — fix applied but NOT verified")
|
|
474
|
+
: "";
|
|
475
|
+
return `git transport: ${report.status}${parts.length ? ` (${parts.join(", ")})` : ""}${fixed}`;
|
|
476
|
+
}
|
package/src/run.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { stackLeaseIdFromEnv, withStackLeaseCorrelation } from "./stack-lease-co
|
|
|
11
11
|
import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "node:fs/promises";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
|
-
import { spawn } from "node:child_process";
|
|
14
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { performance } from "node:perf_hooks";
|
|
17
17
|
import { callToolJson } from "./api.mjs";
|
|
@@ -26,7 +26,7 @@ import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
|
|
|
26
26
|
import { attestTelemetryCredential, loadTelemetryIdentity } from "./telemetry-config.mjs";
|
|
27
27
|
import { VERSION } from "./version.mjs";
|
|
28
28
|
import {
|
|
29
|
-
TERMINAL_RETRY_BUDGET_MS, DELIVERY_CALL_TIMEOUT_MS, callWithContextIdentity, classifyTerminalUpdateFailure,
|
|
29
|
+
TERMINAL_RETRY_BUDGET_MS, DELIVERY_CALL_TIMEOUT_MS, callWithContextIdentity, classifyTerminalUpdateFailure, contextServerUrl,
|
|
30
30
|
ensureRunDelivery, parkedRecord, runDelivery, terminalUpdateAccepted,
|
|
31
31
|
} from "./run-delivery.mjs";
|
|
32
32
|
|
|
@@ -221,6 +221,154 @@ export function commandHash(command) { return createHash("sha256").update(JSON.s
|
|
|
221
221
|
export function environmentHash(cwd, environment) { return createHash("sha256").update(`${cwd}\0${environment}`).digest("hex"); }
|
|
222
222
|
export function receiptPath(runId) { return join(homedir(), ".botbuddy", "command-runs", `${runId}.receipt.json`); }
|
|
223
223
|
export function contextPath(runId) { return join(homedir(), ".botbuddy", "command-runs", `${runId}.context.json`); }
|
|
224
|
+
|
|
225
|
+
// A command receipt is evidence about one checkout revision, not a claim that
|
|
226
|
+
// its working tree was clean. Resolving this once at launch lets recovery
|
|
227
|
+
// distinguish a stale callback from an unrelated later command without ever
|
|
228
|
+
// returning the command line itself.
|
|
229
|
+
export function sourceSha(cwd, { exec = execFileSync } = {}) {
|
|
230
|
+
try {
|
|
231
|
+
const sha = String(exec("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })).trim();
|
|
232
|
+
return /^[0-9a-f]{40,64}$/i.test(sha) ? sha.toLowerCase() : null;
|
|
233
|
+
} catch { return null; }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const TERMINAL_COMMAND_STATUSES = new Set(["success", "failure", "cancelled", "timed_out", "owner_lost"]);
|
|
237
|
+
const TERMINAL_REMOTE_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out", "owner_lost", "invalidated"]);
|
|
238
|
+
const REMOTE_STATUS_FOR_COMMAND_STATUS = Object.freeze({
|
|
239
|
+
success: "succeeded",
|
|
240
|
+
failure: "failed",
|
|
241
|
+
cancelled: "cancelled",
|
|
242
|
+
timed_out: "timed_out",
|
|
243
|
+
owner_lost: "owner_lost",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
function parseOwnedRunFile(raw, runId) {
|
|
247
|
+
try {
|
|
248
|
+
const value = JSON.parse(raw);
|
|
249
|
+
return value && value.run_id === runId ? value : null;
|
|
250
|
+
} catch { return null; }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function freshContext(context, now) {
|
|
254
|
+
const launchedAt = Date.parse(context?.launched_at ?? "");
|
|
255
|
+
const timeoutMs = Number(context?.timeout_seconds) * 1_000;
|
|
256
|
+
return Number.isFinite(launchedAt) && Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
257
|
+
&& now <= launchedAt + timeoutMs + TIMEOUT_GRACE_MS + 60_000;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function publicRemoteSummary(result) {
|
|
261
|
+
if (!result?.ok || result.isError || !result.data || typeof result.data !== "object") return { state: "unavailable" };
|
|
262
|
+
const status = typeof result.data.status === "string" ? result.data.status : null;
|
|
263
|
+
return {
|
|
264
|
+
state: status ? "available" : "unavailable",
|
|
265
|
+
...(status ? { status } : {}),
|
|
266
|
+
...(typeof result.data.exit_code === "number" ? { exit_code: result.data.exit_code } : {}),
|
|
267
|
+
...(typeof result.data.ended_at === "string" ? { ended_at: result.data.ended_at } : {}),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function terminalOutcomesAgree(receipt, remote) {
|
|
272
|
+
if (!receipt || remote.state !== "available") return false;
|
|
273
|
+
if (REMOTE_STATUS_FOR_COMMAND_STATUS[receipt.status] !== remote.status) return false;
|
|
274
|
+
// A missing exit code is meaningful for cancellation, timeout, and
|
|
275
|
+
// owner-loss. When both sides do have one, a disagreement is never hidden.
|
|
276
|
+
return typeof receipt.exit_code !== "number" || typeof remote.exit_code !== "number"
|
|
277
|
+
|| receipt.exit_code === remote.exit_code;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Reconcile only evidence the current OS user and owning BotBuddy agent can
|
|
281
|
+
// read. This is intentionally advisory: an inspector must never terminate a
|
|
282
|
+
// worker, replay a callback, or make a retry safe merely by observing a file.
|
|
283
|
+
export async function inspectRun(runId, {
|
|
284
|
+
call = callToolJson,
|
|
285
|
+
read = readFile,
|
|
286
|
+
now = Date.now(),
|
|
287
|
+
processAlive = workerProcessGroupAlive,
|
|
288
|
+
receiptFile = receiptPath,
|
|
289
|
+
contextFile = contextPath,
|
|
290
|
+
} = {}) {
|
|
291
|
+
if (typeof runId !== "string" || !runId.trim()) {
|
|
292
|
+
return { schema_version: RUN_SCHEMA_VERSION, classification: "rejected", rerun_safe: false, cancellation_safe: false, error: "run_id_required" };
|
|
293
|
+
}
|
|
294
|
+
const id = runId.trim();
|
|
295
|
+
const [receiptRaw, contextRaw] = await Promise.all([
|
|
296
|
+
read(receiptFile(id), "utf8").catch(() => null),
|
|
297
|
+
read(contextFile(id), "utf8").catch(() => null),
|
|
298
|
+
]);
|
|
299
|
+
const receipt = typeof receiptRaw === "string" ? parseOwnedRunFile(receiptRaw, id) : null;
|
|
300
|
+
const context = typeof contextRaw === "string" ? parseOwnedRunFile(contextRaw, id) : null;
|
|
301
|
+
// A durable run belongs to the agent that launched it. Prefer its sealed
|
|
302
|
+
// session identity; older contexts must use a current worktree session and
|
|
303
|
+
// never silently fall back to an unrelated durable machine credential.
|
|
304
|
+
const remoteResult = await callWithContextIdentity(context, call)("get_command_run", { run_id: id }, {
|
|
305
|
+
...(context ? { serverUrl: contextServerUrl(context) } : {}),
|
|
306
|
+
}).catch(() => null);
|
|
307
|
+
const remote = publicRemoteSummary(remoteResult);
|
|
308
|
+
const receiptTerminal = Boolean(receipt && TERMINAL_COMMAND_STATUSES.has(receipt.status));
|
|
309
|
+
const receiptFingerprint = receipt && typeof receipt.command_hash === "string" && /^[a-f0-9]{64}$/i.test(receipt.command_hash)
|
|
310
|
+
&& typeof receipt.source_sha === "string" && /^[a-f0-9]{40,64}$/i.test(receipt.source_sha);
|
|
311
|
+
const fingerprintMismatch = Boolean(context && (
|
|
312
|
+
(context.command_hash && context.command_hash !== receipt?.command_hash)
|
|
313
|
+
|| (context.source_sha && context.source_sha !== receipt?.source_sha)
|
|
314
|
+
));
|
|
315
|
+
const contextMatchesReceipt = !fingerprintMismatch;
|
|
316
|
+
const sourceBound = Boolean(receiptTerminal && receiptFingerprint && contextMatchesReceipt);
|
|
317
|
+
|
|
318
|
+
if (receiptTerminal) {
|
|
319
|
+
// A context that identifies a different command/revision must never be
|
|
320
|
+
// promoted to completed evidence, even if its receipt is terminal.
|
|
321
|
+
if (fingerprintMismatch) {
|
|
322
|
+
return {
|
|
323
|
+
schema_version: RUN_SCHEMA_VERSION,
|
|
324
|
+
run_id: id,
|
|
325
|
+
classification: "unknown",
|
|
326
|
+
rerun_safe: false,
|
|
327
|
+
cancellation_safe: false,
|
|
328
|
+
source_bound: false,
|
|
329
|
+
local: { status: null, exit_code: null },
|
|
330
|
+
remote,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const remoteTerminal = remote.state === "available" && TERMINAL_REMOTE_STATUSES.has(remote.status);
|
|
334
|
+
const outcomesAgree = remoteTerminal && terminalOutcomesAgree(receipt, remote);
|
|
335
|
+
return {
|
|
336
|
+
schema_version: RUN_SCHEMA_VERSION,
|
|
337
|
+
run_id: id,
|
|
338
|
+
classification: outcomesAgree ? "completed" : remoteTerminal ? "completed_locally_remote_conflict" : remote.status === "registered" || remote.status === "running" || remote.status === "approval_required" ? "completed_locally_remote_stale" : "completed_locally_remote_unknown",
|
|
339
|
+
rerun_safe: false,
|
|
340
|
+
cancellation_safe: false,
|
|
341
|
+
source_bound: sourceBound,
|
|
342
|
+
local: { status: receipt.status, exit_code: receipt.exit_code ?? null },
|
|
343
|
+
remote,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const pids = [context?.worker_pid, context?.workload_pid].filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
348
|
+
if (context && freshContext(context, now) && pids.some((pid) => processAlive(pid))) {
|
|
349
|
+
return {
|
|
350
|
+
schema_version: RUN_SCHEMA_VERSION,
|
|
351
|
+
run_id: id,
|
|
352
|
+
classification: "running",
|
|
353
|
+
rerun_safe: false,
|
|
354
|
+
cancellation_safe: false,
|
|
355
|
+
source_bound: false,
|
|
356
|
+
local: { status: null, exit_code: null },
|
|
357
|
+
remote,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
schema_version: RUN_SCHEMA_VERSION,
|
|
363
|
+
run_id: id,
|
|
364
|
+
classification: "unknown",
|
|
365
|
+
rerun_safe: false,
|
|
366
|
+
cancellation_safe: false,
|
|
367
|
+
source_bound: false,
|
|
368
|
+
local: { status: null, exit_code: null },
|
|
369
|
+
remote,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
224
372
|
function cancellationPath(contextFile) { return `${contextFile}.cancelled`; }
|
|
225
373
|
|
|
226
374
|
async function markCancellation(contextFile, runId) {
|
|
@@ -250,6 +398,8 @@ async function preWorkloadCancellationReceipt(context) {
|
|
|
250
398
|
ended_at: now,
|
|
251
399
|
exit_code: null,
|
|
252
400
|
signal: "SIGTERM",
|
|
401
|
+
command_hash: context.command_hash ?? commandHash(context.command ?? []),
|
|
402
|
+
source_sha: context.source_sha ?? null,
|
|
253
403
|
recovery_command: null,
|
|
254
404
|
bounded_log: "",
|
|
255
405
|
output_characters: 0,
|
|
@@ -347,7 +497,7 @@ function rejectedSessionReceipt(error, runId) {
|
|
|
347
497
|
|
|
348
498
|
export async function launchRun(argv, {
|
|
349
499
|
cwd = process.cwd(), spawnImpl = spawn, call = null, childEnv = null, testRun = null,
|
|
350
|
-
resolveCredential = null, renewCredential = selfHealAgentSession, env = process.env,
|
|
500
|
+
resolveCredential = null, renewCredential = selfHealAgentSession, env = process.env, getSourceSha = sourceSha,
|
|
351
501
|
} = {}) {
|
|
352
502
|
const parsed = parseRunArgs(argv, env);
|
|
353
503
|
let managedCredential = null;
|
|
@@ -376,12 +526,14 @@ export async function launchRun(argv, {
|
|
|
376
526
|
const effectiveCall = (t, a) => (call ?? callToolJson)(t, a,
|
|
377
527
|
opts.sessionToken ? { auth: { "x-agent-api-key": opts.sessionToken } } : {});
|
|
378
528
|
const runId = randomUUID();
|
|
529
|
+
const commandFingerprint = commandHash(command);
|
|
530
|
+
const launchSourceSha = getSourceSha(cwd);
|
|
379
531
|
const registerArgs = () => ({
|
|
380
532
|
// Omit session_id entirely when only a token is present — the backend derives
|
|
381
533
|
// it. A plain id is still sent (and honoured) when supplied.
|
|
382
534
|
...(opts.sessionId ? { session_id: opts.sessionId } : {}),
|
|
383
535
|
run_id: runId, tool_category: opts.category,
|
|
384
|
-
command_hash:
|
|
536
|
+
command_hash: commandFingerprint, ...(launchSourceSha ? { source_sha: launchSourceSha } : {}), environment_hash: environmentHash(cwd, opts.environment),
|
|
385
537
|
environment: opts.environment, command_kind: opts.kind, expected_duration_seconds: opts.expectedDuration,
|
|
386
538
|
rerun_reason: opts.rerunReason,
|
|
387
539
|
});
|
|
@@ -411,7 +563,7 @@ export async function launchRun(argv, {
|
|
|
411
563
|
// every callback is attributable to the same agent. It deliberately stays
|
|
412
564
|
// outside child_env, receipts, and the workload's process environment.
|
|
413
565
|
const knownSession = opts.sessionId ?? managedCredential?.identity?.sessionId ?? managedCredential?.sessionId;
|
|
414
|
-
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(), server_url: SERVER_URL
|
|
566
|
+
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, command_hash: commandFingerprint, source_sha: launchSourceSha, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString(), server_url: SERVER_URL, ...(opts.sessionToken ? { callback_session_token: opts.sessionToken } : {}), ...(knownSession ? { callback_session_id: knownSession } : {}), ...(childEnv && Object.keys(childEnv).length ? { child_env: childEnv } : {}), ...(testRun ? { test_run: testRun } : {}) });
|
|
415
567
|
const [file, args] = workerInvocation(context);
|
|
416
568
|
try {
|
|
417
569
|
const worker = spawnImpl(file, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
@@ -804,7 +956,7 @@ export async function cancelRun(runId, {
|
|
|
804
956
|
async function finalize(context, state, call, retry = {}) {
|
|
805
957
|
const boundedLog = String(state.output ?? "").slice(-8192);
|
|
806
958
|
const outputCharacters = state.outputCharacters ?? String(state.output ?? "").length;
|
|
807
|
-
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 };
|
|
959
|
+
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, command_hash: context.command_hash ?? commandHash(context.command ?? []), source_sha: context.source_sha ?? null, 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 };
|
|
808
960
|
await writeJson(context.receipt_path, receipt);
|
|
809
961
|
const bytes = await stat(context.receipt_path);
|
|
810
962
|
const sha256 = createHash("sha256").update(await readFile(context.receipt_path)).digest("hex");
|
|
@@ -882,6 +1034,12 @@ export async function cmdRun(args) {
|
|
|
882
1034
|
if (args.includes("--json")) oneLine({ schema_version: RUN_SCHEMA_VERSION, ...result }, false);
|
|
883
1035
|
return;
|
|
884
1036
|
}
|
|
1037
|
+
if (args[0] === "inspect") {
|
|
1038
|
+
const result = await inspectRun(args[1] ?? "");
|
|
1039
|
+
oneLine(result, false);
|
|
1040
|
+
process.exitCode = result.classification === "rejected" ? EXIT.INVALID : EXIT.OK;
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
885
1043
|
if (args[0] === "cancel") {
|
|
886
1044
|
const result = await cancelRun(args[1] ?? "");
|
|
887
1045
|
oneLine(result.receipt, false);
|
|
@@ -889,7 +1047,7 @@ export async function cmdRun(args) {
|
|
|
889
1047
|
return;
|
|
890
1048
|
}
|
|
891
1049
|
if (args[0] === "help" || args[0] === "--help") {
|
|
892
|
-
console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run cancel <run_id>\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
|
|
1050
|
+
console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run inspect <run_id>\nbotbuddy run cancel <run_id>\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
|
|
893
1051
|
return;
|
|
894
1052
|
}
|
|
895
1053
|
// BOT-1701 (AC-6): one stderr nudge when a tier-3 legacy flag/env alias is used
|