Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 7 additions & 19 deletions scripts/agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
// An agent that starts buildkite-agent and runs others services.

import { copyFileSync, existsSync, readFileSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { darwinReleaseTier } from "./darwin-ci/lib/release-tier.mjs";
import {
getAbi,
getAbiVersion,
Expand Down Expand Up @@ -36,22 +37,8 @@ import {
const BUILDKITE_TOKEN_SECRET = "buildkite/agent-token";
const AZURE_KEYVAULT = "bun-ci";
const AZURE_TOKEN_SECRET = "buildkite-agent-token";

// macOS major-version thresholds for the `release-tier` agent tag.
// >= LATEST -> "latest" (current macOS; arm64-only in practice)
// >= PREVIOUS -> "previous" (recent-but-not-current; 14/15 today)
// else -> "oldest" (min-supported; 13 today)
// Bump LATEST when a new macOS ships and the first runner on it is online.
// Bump PREVIOUS when the floor of "recent" moves.
const LATEST_DARWIN_RELEASE = 26;
const PREVIOUS_DARWIN_RELEASE = 14;

function darwinReleaseTier(distroVersion) {
const major = parseInt(distroVersion?.split(".")[0] || "0");
if (major >= LATEST_DARWIN_RELEASE) return "latest";
if (major >= PREVIOUS_DARWIN_RELEASE) return "previous";
return "oldest";
}
// must match the import above; install() copies it next to agent.mjs at the same relative path
const RELEASE_TIER_MODULE = "darwin-ci/lib/release-tier.mjs";

/**
* @param {"install" | "start"} action
Expand Down Expand Up @@ -149,7 +136,8 @@ async function doBuildkiteAgent(action, cliOptions = {}) {
// Copy this script and its imports into homePath so the launchd plist
// doesn't depend on the checkout that ran `install` sticking around.
const srcDir = fileURLToPath(new URL(".", import.meta.url));
for (const f of ["agent.mjs", "utils.mjs"]) {
for (const f of ["agent.mjs", "utils.mjs", RELEASE_TIER_MODULE]) {
mkdir(dirname(join(homePath, f)));
copyFileSync(join(srcDir, f), join(homePath, f));
}
// Stable node path (the Homebrew/usr-local symlink, not a Cellar version
Expand Down Expand Up @@ -370,7 +358,7 @@ async function doBuildkiteAgent(action, cliOptions = {}) {
// ci.mjs targets darwin test jobs by `release-tier` so each PR runs on
// distinct OS-age pools without needing per-box config. arm64 uses
// latest+previous; x64 uses previous+oldest (Intel can't run latest).
"release-tier": isMacOS ? darwinReleaseTier(distroVersion) : undefined,
"release-tier": isMacOS ? darwinReleaseTier(parseInt(distroVersion?.split(".")[0] || "0")) : undefined,
"ephemeral": ephemeral || false,
"cloud": cloud,
};
Expand Down
8 changes: 7 additions & 1 deletion scripts/darwin-ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ guest's macOS version, and a guest cannot be newer than its host.
host.sh first contact: installs brew and bun, then runs `main.ts provision`
main.ts provision | setup-user | bake | install-agent
lib/ host hardening, tailscale, the unprivileged CI user, tart, bake, agent config
lib/release-tier.mjs the `release-tier` thresholds, also imported by scripts/agent.mjs for bare hosts
hooks/ agent hooks for tart hosts (command, pre-exit, environment)
guest/bake.sh runs inside the guest once, at bake time
guest/job.sh runs inside the guest for every job
Expand All @@ -37,7 +38,12 @@ its hooks path. It asks for one reboot the first time and is re-run after it.
`scripts/bootstrap.sh` on the host and installs the `scripts/agent.mjs` service.

`bake` is safe on a live host: it builds a staging image and swaps it in only
after the toolchain verifies. Re-run it when toolchain pins move.
after the toolchain verifies, under the same lock the command hook clones
with. Re-run it when toolchain pins move.

Everything here runs on whatever bun `host.sh` pinned when the host was first
provisioned, so it sticks to `Bun.spawn` with argv arrays and stays off
`Bun.$`.

## Bringing up a host

Expand Down
15 changes: 7 additions & 8 deletions scripts/darwin-ci/guest/job.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,19 @@ export PATH=/usr/local/bin:/opt/homebrew/bin:/opt/rust/bin:$PATH
# does not source; without them the proxies in /opt/rust/bin look for a toolchain in ~/.rustup and find none.
export RUSTUP_HOME=/opt/rust CARGO_HOME=/opt/rust
export BUILDKITE_BUILD_CHECKOUT_PATH=$HOME/work
set -a; source ~/job.env; set +a
cd ~/work
set -a; source ~/job.env || exit 1; set +a
cd ~/work || exit 1

echo "[guest] macOS $(sw_vers -productVersion) shard=${BUILDKITE_PARALLEL_JOB:-0}/${BUILDKITE_PARALLEL_JOB_COUNT:-1}"
bun install >/dev/null 2>&1 || true
(cd test && bun install >/dev/null 2>&1) || true

shard=()
[ -n "${BUILDKITE_PARALLEL_JOB:-}" ] && shard=(--shard="$BUILDKITE_PARALLEL_JOB" --max-shards="$BUILDKITE_PARALLEL_JOB_COUNT")
# warms node_modules with the guest's bun; the runner repeats both installs with the bun under test and reports those
for dir in . test; do
(cd "$dir" && bun install) >"$HOME/install.out" 2>&1 || { echo "[guest] bun install in $dir failed:"; cat "$HOME/install.out"; }
done

# the runner can leak servers that keep node alive after the run, so wait on it directly and stream its log
log=$HOME/runner.out
: >"$log"
$BUILDKITE_COMMAND "${shard[@]}" >"$log" 2>&1 &
bash -e -o pipefail -c "$BUILDKITE_COMMAND" >"$log" 2>&1 &
runner=$!
tail -f "$log" &
tailer=$!
Expand Down
29 changes: 17 additions & 12 deletions scripts/darwin-ci/hooks/command.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { $ } from "bun";
import { unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { config } from "../lib/config";
import { guest } from "../lib/guest";
import { fail } from "../lib/shell";
import { fail, runInherit, shellQuote } from "../lib/shell";
import { tart } from "../lib/tart";

const env = process.env;
Expand All @@ -26,14 +25,16 @@ const hostOnlyEnv = new Set([

if (!command.includes("runner.node.mjs")) {
console.log("--- running on host (not a test step)");
process.exit((await $`bash -c ${command}`.nothrow()).exitCode);
// same flags buildkite-agent's own command runner uses, so a failing middle line still fails the step
process.exit(await runInherit(["bash", "-e", "-o", "pipefail", "-c", command], { cwd: checkout }));
}

let cleaned = false;
async function cleanup(): Promise<void> {
if (cleaned) return;
cleaned = true;
await tart.destroy(vm);
// pre-exit destroys it again and reaps whatever this misses, so a failure here must not override the test status
await tart.destroy(vm).catch((error: unknown) => console.error(`failed to destroy ${vm}: ${error}`));
}
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
process.on(signal, () => cleanup().finally(() => process.exit(130)));
Expand Down Expand Up @@ -61,10 +62,11 @@ async function runInGuest(): Promise<number> {
console.log(`+++ :test_tube: ${command}`);
const socket = "/tmp/bk-job-api.sock";
const forward = ["-o", "StreamLocalBindUnlink=yes", "-R", `${socket}:${env.BUILDKITE_AGENT_JOB_API_SOCKET}`];
const status = await g.run(
`BUILDKITE_AGENT_JOB_API_SOCKET=${socket} BUILDKITE_AGENT_JOB_API_TOKEN=${env.BUILDKITE_AGENT_JOB_API_TOKEN ?? ""} /bin/bash ~/job.sh`,
forward,
);
const jobEnv = [
`BUILDKITE_AGENT_JOB_API_SOCKET=${socket}`,
`BUILDKITE_AGENT_JOB_API_TOKEN=${shellQuote(env.BUILDKITE_AGENT_JOB_API_TOKEN ?? "")}`,
];
const status = await g.run(`${jobEnv.join(" ")} /bin/bash ~/job.sh`, forward);
Comment on lines +65 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
root="$(pwd)"

ast-grep outline "$root/scripts/darwin-ci/lib/guest.ts" --items all --type function --match 'run'
rg -n -C 8 'Bun\.spawn|ssh|command' "$root/scripts/darwin-ci/lib/guest.ts"

Repository: oven-sh/bun

Length of output: 1812


🏁 Script executed:

#!/bin/bash
set -euo pipefail
root="$(pwd)"

printf '%s\n' '--- command.ts ---'
cat -n "$root/scripts/darwin-ci/hooks/command.ts"

printf '%s\n' '--- guest shell helpers ---'
cat -n "$root/scripts/darwin-ci/lib/shell.ts"

printf '%s\n' '--- job scripts and env references ---'
rg -n -C 10 'job\.env|BUILDKITE_AGENT_JOB_API|writeFileSync|unlinkSync|env\.' \
  "$root/scripts/darwin-ci"

Repository: oven-sh/bun

Length of output: 28404


🏁 Script executed:

#!/bin/bash
set -euo pipefail
root="$(pwd)"

printf '%s\n' '--- relevant call graph ---'
ast-grep outline "$root/scripts/darwin-ci/hooks/command.ts" --items all --type function
ast-grep outline "$root/scripts/darwin-ci/lib/shell.ts" --items all --type function

printf '%s\n' '--- command construction and execution ---'
rg -n -C 15 'runInherit|function run|const run|Bun\.spawn|writeFileSync|unlinkSync|job\.sh|job\.env' \
  "$root/scripts/darwin-ci/hooks/command.ts" \
  "$root/scripts/darwin-ci/lib/shell.ts" \
  "$root/scripts/darwin-ci/guest"

printf '%s\n' '--- files containing job scripts ---'
git ls-files "$root/scripts/darwin-ci" | sed -n '/job\|env/p'

Repository: oven-sh/bun

Length of output: 18786


🏁 Script executed:

#!/bin/bash
set -euo pipefail
root="$(pwd)"

printf '%s\n' '--- command.ts ---'
sed -n '1,150p' "$root/scripts/darwin-ci/hooks/command.ts"

printf '%s\n' '--- shell.ts ---'
sed -n '1,220p' "$root/scripts/darwin-ci/lib/shell.ts"

printf '%s\n' '--- guest job files ---'
rg --files "$root/scripts/darwin-ci" | rg '(^|/)(job(\.sh|\.env)?|.*job.*)$' || true
rg -n -C 8 'job\.env|BUILDKITE_AGENT_JOB_API|source .*env|set -a|export ' "$root/scripts/darwin-ci" || true

Repository: oven-sh/bun

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -euo pipefail
root="$(pwd)"

python3 - "$root/scripts/darwin-ci/hooks/command.ts" <<'PY'
import sys
from pathlib import Path
p = Path(sys.argv[1])
for i, line in enumerate(p.read_text().splitlines(), 1):
    if 1 <= i <= 130:
        print(f"{i:4}: {line}")
PY

printf '%s\n' '--- all environment-file and token consumers ---'
rg -n -C 12 'jobEnv|job\.env|BUILDKITE_AGENT_JOB_API|envFile|environment' "$root/scripts/darwin-ci" || true

Repository: oven-sh/bun

Length of output: 15986


🏁 Script executed:

#!/bin/bash
set -euo pipefail
root="$(pwd)"

python3 - "$root/scripts/darwin-ci/hooks/command.ts" <<'PY'
import sys
from pathlib import Path
lines = Path(sys.argv[1]).read_text().splitlines()
for start, end in ((1, 45), (55, 110)):
    print(f"--- lines {start}-{end} ---")
    for n in range(start, min(end, len(lines)) + 1):
        print(f"{n:4}: {lines[n-1]}")
PY

printf '%s\n' '--- execution helper definitions ---'
rg -n -C 20 'runInherit|export async function run|function run|Bun\.spawn' \
  "$root/scripts/darwin-ci" || true

printf '%s\n' '--- job.sh and job.env references ---'
rg -n -C 15 'job\.sh|job\.env|BUILDKITE_AGENT_JOB_API_SOCKET|BUILDKITE_AGENT_JOB_API_TOKEN' \
  "$root" -g '!node_modules' -g '!build' -g '!dist' || true

Repository: oven-sh/bun

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import os
import subprocess
import tempfile

def shell_quote(value):
    return "'" + value.replace("'", "'\\''") + "'"

# Model the exact pushEnv serialization for a matching but invalid environment key.
key = "BUILDKITE_INJECT\nprintf vulnerable > \"$MARKER\" #"
value = "safe"
with tempfile.TemporaryDirectory() as d:
    env_file = os.path.join(d, "job.env")
    marker = os.path.join(d, "marker")
    with open(env_file, "w") as f:
        f.write(f"{key}={shell_quote(value)}\n")
    result = subprocess.run(
        ["/bin/bash", "-c", "set -a; source \"$1\"; set +a"],
        args=["bash", env_file],
        env={**os.environ, "MARKER": marker},
        capture_output=True,
        text=True,
    )
    print("invalid-key-source-exit:", result.returncode)
    print("invalid-key-created-file:", os.path.exists(marker))
    print("invalid-key-stderr:", result.stderr.strip())

# Show that mode=0o600 does not make an existing predictable file private.
with tempfile.TemporaryDirectory() as d:
    path = os.path.join(d, "bk-job.env")
    with open(path, "w") as f:
        f.write("old\n")
    os.chmod(path, 0o644)
    with open(path, "w") as f:
        f.write("secret\n")
    print("existing-file-mode-after-write:", oct(os.stat(path).st_mode & 0o777))
PY

printf '%s\n' '--- relevant filesystem API documentation availability ---'
node -e 'try { console.log(require.resolve("node:fs")) } catch { console.log("node:fs unavailable") }'

Repository: oven-sh/bun

Length of output: 396


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import os
import subprocess
import tempfile

def shell_quote(value):
    return "'" + value.replace("'", "'\\''") + "'"

key = "BUILDKITE_INJECT\nprintf vulnerable > \"$MARKER\" #"
value = "safe"

with tempfile.TemporaryDirectory() as d:
    env_file = os.path.join(d, "job.env")
    marker = os.path.join(d, "marker")
    with open(env_file, "w") as f:
        f.write(f"{key}={shell_quote(value)}\n")
    result = subprocess.run(
        ["/bin/bash", "-c", 'set -a; source "$1"; set +a', "--", env_file],
        env={**os.environ, "MARKER": marker},
        capture_output=True,
        text=True,
    )
    print("invalid-key-source-exit:", result.returncode)
    print("invalid-key-created-file:", os.path.exists(marker))
    print("invalid-key-stderr:", result.stderr.strip())

with tempfile.TemporaryDirectory() as d:
    path = os.path.join(d, "bk-job.env")
    with open(path, "w") as f:
        f.write("old\n")
    os.chmod(path, 0o644)
    with open(path, "w") as f:
        f.write("secret\n")
    print("existing-file-mode-after-write:", oct(os.stat(path).st_mode & 0o777))
PY

Repository: oven-sh/bun

Length of output: 335


Keep the API token out of SSH arguments and validate the environment file.

guest.run passes the command to Bun.spawn(["ssh", ..., command]), so shellQuote does not hide the token from process arguments. Add the token to the protected job.env transport and reject unset or empty credentials before starting SSH.

In pushEnv, validate environment keys before writing a file that job.sh sources. The current filter accepts keys containing newlines, which can execute injected commands. Create /tmp/${vm}.env exclusively with private permissions; { mode: 0o600 } does not change an existing file’s mode. Preserve the original push error if cleanup fails.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/darwin-ci/hooks/command.ts` around lines 65 - 69, Update the command
setup around jobEnv and g.run to pass the API token through protected job.env
transport rather than SSH command arguments, and reject missing or empty
credentials before starting SSH. In pushEnv, validate environment keys against
newlines and other unsafe content before writing the sourced file. Create
/tmp/${vm}.env exclusively with private permissions, and preserve the original
push error if cleanup also fails.

Source: Coding guidelines


console.log("--- :outbox_tray: collect reports");
await g.collectReports("work", checkout);
Expand All @@ -87,9 +89,12 @@ async function retryBoot(): Promise<string | undefined> {
async function pushEnv(g: ReturnType<typeof guest>): Promise<void> {
const lines = Object.entries(env)
.filter(([key]) => /^(BUILDKITE|BUN_|EXPECTED_PLATFORM_)|^(CI|ASAN_OPTIONS)$/.test(key) && !hostOnlyEnv.has(key))
.map(([key, value]) => `${key}=${$.escape(String(value ?? ""))}`);
.map(([key, value]) => `${key}=${shellQuote(value ?? "")}`);
const file = `/tmp/${vm}.env`;
writeFileSync(file, lines.join("\n") + "\n");
await g.push(file, "job.env");
unlinkSync(file);
writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
try {
await g.push(file, "job.env");
} finally {
unlinkSync(file);
}
Comment on lines 93 to +99

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Verify Bun's node:fs writeFileSync mode and symlink behavior for existing paths, and identify the supported exclusive non-following temporary-file API.

💡 Result:

Bun's node:fs implementation of writeFileSync supports standard Node.js file system flags, including 'wx' [1]. The 'wx' flag is the standard mechanism for exclusive, non-following file creation; it combines O_EXCL and O_CREAT behavior, failing if the path already exists (including symlinks) [2][3][4][1]. Regarding the requested "exclusive non-following temporary-file API," Bun does not provide a single node:fs method that combines "exclusive," "non-following," and "temporary-file" operations in one atomic call beyond standard fs.open flags [5][6]. However, developers typically achieve this pattern in Bun (and Node.js) by using fs.openSync with a combination of flags—such as O_CREAT, O_EXCL, and O_NOFOLLOW—to safely open a unique file path [5][2][4]. Bun maintains high compatibility with the Node.js fs module [5][7]. Specifically: - Symlink behavior: When using writeFileSync or other fs methods, Bun follows symbolic links by default unless specific flags (like O_NOFOLLOW) are used via lower-level fs.open operations [5][2]. - Exclusive/Non-following: The 'wx' flag ensures that the operation fails if the path exists (regardless of whether it is a file or a symlink), fulfilling the "exclusive" and "non-following" requirements [2][3][4][1]. - Native optimization: While node:fs is recommended for compatibility, Bun also provides Bun.write(), which is optimized for performance and handles file creation/overwriting automatically but does not directly expose the same granular flag-based control as the node:fs module [7][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- target file ---'
sed -n '1,130p' scripts/darwin-ci/hooks/command.ts

printf '%s\n' '--- related temporary-file patterns ---'
rg -n --glob '*.ts' --glob '*.js' 'mkdtempSync|openSync\\([^\\n]*[\"'\"']wx|writeFileSync\\([^\\n]*flag|O_EXCL|O_NOFOLLOW|unlinkSync' scripts src test 2>/dev/null | head -200

Repository: oven-sh/bun

Length of output: 3789


🏁 Script executed:

#!/bin/bash
set -eu

node - <<'JS'
const fs = require("node:fs");

const flags = fs.constants.O_CREAT | fs.constants.O_EXCL;
console.log(JSON.stringify({
  wxIncludesCreate: (fs.constants.O_CREAT & flags) === fs.constants.O_CREAT,
  wxIncludesExclusive: (fs.constants.O_EXCL & flags) === fs.constants.O_EXCL,
}));

let observed;
try {
  try {
    throw new Error("push failed");
  } finally {
    throw new Error("unlink failed");
  }
} catch (error) {
  observed = error.message;
}
console.log(JSON.stringify({finallyErrorReplacesPrimary: observed === "unlink failed"}));
JS

Repository: oven-sh/bun

Length of output: 239


Make the temporary environment file exclusive and cleanup-safe.

writeFileSync runs before the try, and { mode: 0o600 } does not protect an existing path. Create the file atomically with openSync(..., "wx", 0o600) or a unique temporary directory. Include creation and writing in the cleanup scope. Preserve the original operation error when unlinkSync also fails.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/darwin-ci/hooks/command.ts` around lines 93 - 99, Update the
temporary file handling around writeFileSync and g.push so creation is atomic
and exclusive with 0o600 permissions, including file creation and writing inside
the try scope. Ensure cleanup runs only after a successfully created file, and
preserve the original operation error if unlinkSync fails during cleanup.

Source: Coding guidelines

}
23 changes: 18 additions & 5 deletions scripts/darwin-ci/hooks/pre-exit.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
import { $ } from "bun";
import { readdirSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { succeeds } from "../lib/shell";
import { tart } from "../lib/tart";

await tart.destroy(`bk-${process.env.BUILDKITE_JOB_ID ?? "none"}`);
let failed = false;
async function reap(name: string, action: () => Promise<void>): Promise<void> {
try {
await action();
} catch (error) {
failed = true;
console.error(`pre-exit: could not remove ${name}: ${error}`);
}
}

const own = `bk-${process.env.BUILDKITE_JOB_ID ?? "none"}`;
await reap(own, () => tart.destroy(own));
Comment on lines +17 to +18

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Refuse destructive cleanup when BUILDKITE_JOB_ID is missing or empty.

?? "none" converts an unset ID into bk-none. An empty ID produces bk-. If either VM exists, this hook can stop and delete an unrelated guest. Validate the job ID before calling tart.destroy.

As per coding guidelines: “Distinguish empty, zero, and unset” and “Validate untrusted input before ... side effects.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/darwin-ci/hooks/pre-exit.ts` around lines 17 - 18, Validate
BUILDKITE_JOB_ID before constructing own or invoking the destructive
reap/tart.destroy cleanup, rejecting both unset and empty values. Only allow
cleanup when the ID is a valid non-empty value, while preserving the existing
bk-prefixed ownership flow for valid IDs.

Source: Coding guidelines


// a bk-* guest with no `tart run` behind it belongs to a job that died without its cleanup running
const vms = join(homedir(), ".tart", "vms");
const tenMinutesAgo = Date.now() - 10 * 60_000;
for (const name of readdirSync(vms).filter((name: string) => name.startsWith("bk-"))) {
const stat = statSync(join(vms, name), { throwIfNoEntry: false });
if (!stat || stat.mtimeMs > tenMinutesAgo) continue;
Comment on lines 21 to 25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle VM discovery I/O failures explicitly.

readdirSync(vms) and statSync(...) are outside reap. A missing or inaccessible Tart directory can abort the loop, skip later guests, and bypass the controlled failed status path. Mark directory failures, handle per-entry failures, and finish with an explicit nonzero status.

As per coding guidelines: “Never swallow failures” and “Every error, abort, and timeout path must complete the operation.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/darwin-ci/hooks/pre-exit.ts` around lines 21 - 25, Update the VM
discovery flow around readdirSync, statSync, and reap so directory-read failures
are caught and recorded in the existing failed status path, while per-entry stat
failures are handled without aborting discovery of later VMs. Ensure every
discovery error is surfaced rather than swallowed and the hook completes with an
explicit nonzero exit status when any failure occurs.

Source: Coding guidelines

if (await succeeds($`pgrep -f ${`tart run ${name}`}`)) continue;
await tart.remove(name);
console.log(`pre-exit: reaped orphan guest ${name}`);
if (await succeeds(["pgrep", "-f", `tart run ${name}`])) continue;
await reap(name, async () => {
await tart.remove(name);
console.log(`pre-exit: reaped orphan guest ${name}`);
});
}
Comment on lines +26 to 31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
root="$(pwd)"

ast-grep outline "$root/scripts/darwin-ci/lib/tart.ts" --items all
rg -n -C 8 'remove|delete|run' "$root/scripts/darwin-ci/lib/tart.ts"

Repository: oven-sh/bun

Length of output: 3289


🏁 Script executed:

#!/bin/bash
set -euo pipefail
root="$(pwd)"

printf '%s\n' '--- pre-exit.ts ---'
cat -n "$root/scripts/darwin-ci/hooks/pre-exit.ts"

printf '%s\n' '--- shell.ts outline and relevant implementations ---'
ast-grep outline "$root/scripts/darwin-ci/lib/shell.ts" --items all
rg -n -C 12 'function (succeeds|probe|run|spawn)|export .*succeeds|succeeds|probe' "$root/scripts/darwin-ci/lib/shell.ts"

printf '%s\n' '--- pre-exit references and tart lifecycle callers ---'
rg -n -C 6 'pre-exit|reap\(|tart\.remove|tart\.destroy|pgrep' "$root/scripts/darwin-ci"

Repository: oven-sh/bun

Length of output: 16260


🌐 Web query:

macOS pgrep exit status 0 1 2 3 no processes matched man page

💡 Result:

On macOS, the pgrep utility follows standard BSD-style exit codes as documented in its manual page [1][2]. The exit statuses are defined as follows: 0: One or more processes were matched [1][2]. 1: No processes were matched [1][2]. 2: Invalid options were specified on the command line [1][2]. 3: An internal error occurred [1][2]. Because pgrep returns an exit status of 1 when no matching processes are found, it is common in shell scripts to handle this behavior specifically, especially when using set -e (which causes a script to exit immediately if any command returns a non-zero status) [3]. To prevent your script from exiting when no processes are matched, you can append || true or another appropriate fallback to the pgrep command [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
root="$(pwd)"

printf '%s\n' '--- tart delete and destroy implementation ---'
cat -n "$root/scripts/darwin-ci/lib/tart.ts" | sed -n '35,66p'

printf '%s\n' '--- read-only exit-code probe ---'
python3 - <<'PY'
import shutil
import subprocess

pgrep = shutil.which("pgrep")
if not pgrep:
    print("pgrep: unavailable")
    raise SystemExit(0)

cases = [
    ("no-match", [pgrep, "-f", "coderabbit-no-such-process-8f8c9d7b"]),
    ("invalid-pattern", [pgrep, "-f", "["]),
]
for label, argv in cases:
    result = subprocess.run(argv, stdin=subprocess.DEVNULL,
                            stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                            text=True)
    print(f"{label}: exit={result.returncode}, stdout={result.stdout!r}, stderr={result.stderr!r}")
    print(f"{label}: succeeds-equivalent={result.returncode == 0}")
PY

Repository: oven-sh/bun

Length of output: 1782


Fail closed on orphan-process detection.

succeeds treats every nonzero pgrep exit code as false. This includes invalid-option and internal-error statuses, so probe failures can trigger tart.remove(name). Accept only pgrep exit code 1 as “no match”; report all other failures.

Use tart.destroy(name) or equivalent lock-aware deletion logic. tart.remove(name) does not retry while tart run still holds the VM lock.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/darwin-ci/hooks/pre-exit.ts` around lines 26 - 31, Update the orphan
detection around succeeds and pgrep to fail closed: treat only exit code 1 as
“no matching process,” and report any other pgrep failure instead of deleting
the guest. In the reap callback, replace tart.remove(name) with
tart.destroy(name) or equivalent lock-aware deletion that retries until tart run
releases the VM lock.

Source: Coding guidelines

process.exit(failed ? 1 : 0);

Check warning on line 32 in scripts/darwin-ci/hooks/pre-exit.ts

View check run for this annotation

Claude / Claude Code Review

pre-exit fails the job when reaping an unrelated orphan fails

The orphan-reap loop sets the same `failed` flag as the own-guest destroy, so a failure deleting some *other* job's leftover VM makes pre-exit exit 1 — e.g. with `spawn=2`, two concurrent pre-exits both see the same >10-min orphan, both call strict `tart.remove`, and the loser gets "does not exist" → `failed = true`. Orphan reaping is best-effort auxiliary cleanup (the nightly reboot daemon already backstops it); only a failure to destroy `own` should influence the exit code — log-and-continue f
Comment on lines +26 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The orphan-reap loop sets the same failed flag as the own-guest destroy, so a failure deleting some other job's leftover VM makes pre-exit exit 1 — e.g. with spawn=2, two concurrent pre-exits both see the same >10-min orphan, both call strict tart.remove, and the loser gets "does not exist" → failed = true. Orphan reaping is best-effort auxiliary cleanup (the nightly reboot daemon already backstops it); only a failure to destroy own should influence the exit code — log-and-continue for the orphan loop.

Extended reasoning...

What changed

Before this PR, tart.remove was .nothrow() and pre-exit.ts fell off the end with an implicit exit 0. Now tart.remove is strict run([bin, 'delete', vm]) (throws on nonzero), reap() catches any throw and sets a shared failed = true, and the script ends with process.exit(failed ? 1 : 0). The same reap() wrapper is used for two very different things:

  1. Destroying this job's own guest (own = bk-${BUILDKITE_JOB_ID})
  2. Opportunistically deleting other jobs' bk-* directories under ~/.tart/vms that are >10 minutes old with no tart run process

Because both paths flip the same flag, a failure in (2) — cleanup of a VM this job never touched — makes pre-exit exit 1.

Concrete trigger

With spawn=2 (the config default in lib/config.ts), two agent workers run on the same host and their pre-exit hooks can execute concurrently. Suppose an earlier job died and left bk-OLD behind:

  1. Job A's pre-exit and Job B's pre-exit both run readdirSync(~/.tart/vms) and both see bk-OLD (>10 min old, no tart run process).
  2. Both call tart.remove('bk-OLD') → run([tart, 'delete', 'bk-OLD']).
  3. Job A wins; tart delete succeeds.
  4. Job B's tart delete bk-OLD exits nonzero ("does not exist" / ENOENT), run() throws, reap() sets failed = true.
  5. Job B's pre-exit calls process.exit(1) even though Job B's own tests passed and its own guest was destroyed cleanly.

The same happens for any other reason tart delete might fail on an unrelated leftover (corrupt VM dir, tart lock contention, permissions).

Why nothing prevents it

tart.destroy(own) (used for the job's own guest) does guard with if (!(await tart.exists(vm))) return, but the orphan loop calls tart.remove(name) directly, which is a bare strict run() with no exists-check and no "does not exist" tolerance. And even if it did check first, the check-then-delete would still be racy across two concurrent pre-exits.

Why this is the wrong shape

REVIEW.md: "Operations the user explicitly requested fail the whole command on any failure … best-effort auxiliary steps (hints, optional deps) degrade quietly instead of aborting." Orphan reaping is textbook best-effort auxiliary work — it's cleaning up after other jobs, and there's already a nightly com.buildkite.cleanup daemon in lib/agent.ts that boots out the agent, tart deletes every bk-*, and reboots the host. A missed orphan here is caught within 24h regardless. The PR description itself says pre-exit "logs a guest it cannot delete and keeps reaping" — it does keep reaping, but then exits 1 at the end, which contradicts that intent for orphans.

Fix

Only let the own guest's destroy failure set failed; log and swallow errors from the orphan loop:

for (const name of readdirSync(vms).filter(n => n.startsWith("bk-"))) {
  ...
  try {
    await tart.remove(name);
    console.log(`pre-exit: reaped orphan guest ${name}`);
  } catch (error) {
    console.error(`pre-exit: could not remove orphan ${name}: ${error}`);
  }
}

(Or drop process.exit(failed ? 1 : 0) entirely, since the command hook already destroyed own and the nightly daemon backstops everything — but keeping it for own is defensible.)

26 changes: 21 additions & 5 deletions scripts/darwin-ci/host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,36 @@ set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
sudo -n true 2>/dev/null || { echo "needs passwordless sudo for $(whoami)"; exit 1; }

if [ "$(uname -m)" = arm64 ]; then prefix=/opt/homebrew; target=aarch64; else prefix=/usr/local; target=x64; fi
# https://github.com/Homebrew/install/commits/master
brew_installer_commit=a34ae4ee9151cbce4c3b33bca7043a972b7ae9a5
brew_installer_sha256=12479a24be3f5307eecac7cde670fad7118640f031229e964f544b1367b52a41
# https://github.com/oven-sh/bun/releases/download/bun-v$bun_version/SHASUMS256.txt
bun_version=1.3.14
bun_sha256_aarch64=d8b96221828ad6f97ac7ac0ab7e95872341af763001e8803e8267652c2652620
bun_sha256_x64=4183df3374623e5bab315c547cfa0974533cd457d86b73b639f7a87974cd6633

if [ "$(uname -m)" = arm64 ]; then prefix=/opt/homebrew; target=aarch64; bun_sha256=$bun_sha256_aarch64; else prefix=/usr/local; target=x64; bun_sha256=$bun_sha256_x64; fi

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

fetch() { # <url> <file> <sha256>
curl -fsSL -o "$2" "$1"
echo "$3 $2" | shasum -a 256 -c - >/dev/null || { echo "checksum mismatch for $1"; exit 1; }
}

if [ ! -x "$prefix/bin/brew" ]; then
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fetch "https://raw.githubusercontent.com/Homebrew/install/$brew_installer_commit/install.sh" "$tmp/brew-install.sh" "$brew_installer_sha256"
NONINTERACTIVE=1 /bin/bash "$tmp/brew-install.sh"
fi
eval "$("$prefix/bin/brew" shellenv)"

if [ ! -x /usr/local/bin/bun ]; then
tmp=$(mktemp -d)
curl -fsSL -o "$tmp/bun.zip" "https://github.com/oven-sh/bun/releases/latest/download/bun-darwin-$target.zip"
fetch "https://github.com/oven-sh/bun/releases/download/bun-v$bun_version/bun-darwin-$target.zip" "$tmp/bun.zip" "$bun_sha256"
unzip -q "$tmp/bun.zip" -d "$tmp"
sudo mkdir -p /usr/local/bin
sudo install -m 755 "$tmp/bun-darwin-$target/bun" /usr/local/bin/bun
Comment on lines 31 to 35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Verify the installed Bun binary, not only its existence.

This condition skips the verified archive path whenever /usr/local/bin/bun already exists. A stale or modified binary then remains installed.

Fetch and verify the pinned archive, then compare its bun binary with the installed binary before deciding whether to install it.

Proposed fix
-if [ ! -x /usr/local/bin/bun ]; then
-  fetch "https://github.com/oven-sh/bun/releases/download/bun-v$bun_version/bun-darwin-$target.zip" "$tmp/bun.zip" "$bun_sha256"
-  unzip -q "$tmp/bun.zip" -d "$tmp"
+fetch "https://github.com/oven-sh/bun/releases/download/bun-v$bun_version/bun-darwin-$target.zip" "$tmp/bun.zip" "$bun_sha256"
+unzip -q "$tmp/bun.zip" -d "$tmp"
+if ! cmp -s "$tmp/bun-darwin-$target/bun" /usr/local/bin/bun 2>/dev/null; then
   sudo mkdir -p /usr/local/bin
   sudo install -m 755 "$tmp/bun-darwin-$target/bun" /usr/local/bin/bun
 fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if [ ! -x /usr/local/bin/bun ]; then
tmp=$(mktemp -d)
curl -fsSL -o "$tmp/bun.zip" "https://github.com/oven-sh/bun/releases/latest/download/bun-darwin-$target.zip"
fetch "https://github.com/oven-sh/bun/releases/download/bun-v$bun_version/bun-darwin-$target.zip" "$tmp/bun.zip" "$bun_sha256"
unzip -q "$tmp/bun.zip" -d "$tmp"
sudo mkdir -p /usr/local/bin
sudo install -m 755 "$tmp/bun-darwin-$target/bun" /usr/local/bin/bun
fetch "https://github.com/oven-sh/bun/releases/download/bun-v$bun_version/bun-darwin-$target.zip" "$tmp/bun.zip" "$bun_sha256"
unzip -q "$tmp/bun.zip" -d "$tmp"
if ! cmp -s "$tmp/bun-darwin-$target/bun" /usr/local/bin/bun 2>/dev/null; then
sudo mkdir -p /usr/local/bin
sudo install -m 755 "$tmp/bun-darwin-$target/bun" /usr/local/bin/bun
fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/darwin-ci/host.sh` around lines 31 - 35, Update the Bun installation
condition in host.sh to validate the existing /usr/local/bin/bun against the
freshly fetched and verified archive binary, rather than checking only
executability. Always fetch and verify the pinned archive, compare its extracted
bun binary with the installed one, and run the existing sudo install steps when
they differ; skip installation only when the binaries match.

rm -rf "$tmp"
fi

rm -rf "$tmp"; trap - EXIT
exec /usr/local/bin/bun "$here/main.ts" provision "$@"
64 changes: 42 additions & 22 deletions scripts/darwin-ci/lib/agent.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { $ } from "bun";
import { join } from "node:path";
import { ciUserHome, ciUserId } from "./ci-user";
import { config, releaseTier } from "./config";
import { config } from "./config";
import { bootstrapCheckout } from "./host";
import { plist } from "./launchd";
import { consoleUser, fail, output, poll, sleep, succeeds, sudoRead, sudoWrite } from "./shell";
import { darwinReleaseTier } from "./release-tier.mjs";
import {
consoleUser,
fail,
poll,
probe,
run,
runInheritOrThrow,
sleep,
spawn,
succeeds,
sudoRead,
sudoWrite,
} from "./shell";

const path = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
const agentLabel = "com.buildkite.buildkite-agent";
Expand All @@ -17,13 +30,14 @@ async function agentToken(): Promise<string> {
);
}

type TartAgentOptions = { release: number; spawn: number };
export type TartAgentOptions = { release: number; spawn: number };

export async function installTartAgent({ release, spawn }: TartAgentOptions): Promise<void> {
export async function installTartAgent({ release, spawn: workers }: TartAgentOptions): Promise<void> {
const user = config.ciUser;
if ((await consoleUser()) !== user)
fail(`${user} is not logged in at the console; run setup-user, reboot, then retry`);
const baked = await succeeds($`sudo -u ${user} -H ${config.tart.bin} get ${config.tart.image}`);
const asCiUser = ["sudo", "-u", user, "-H"];
const baked = await succeeds([...asCiUser, config.tart.bin, "get", config.tart.image]);
if (!baked) fail(`no ${config.tart.image} image for ${user}; run bake as ${user} first`);

const home = await ciUserHome();
Expand All @@ -34,17 +48,17 @@ export async function installTartAgent({ release, spawn }: TartAgentOptions): Pr
const hooks = join(config.installDir, "hooks");
const owner = `${user}:staff`;

await $`sudo install -d -o ${user} -g staff ${builds} ${logs} ${join(home, ".buildkite-agent")}`;
await run(["sudo", "install", "-d", "-o", user, "-g", "staff", builds, logs, join(home, ".buildkite-agent")]);

await sudoWrite(
configPath,
[
`token="${await agentToken()}"`,
`name="%hostname-tart-${release}-%spawn"`,
`tags="queue=${config.queue},os=darwin,arch=aarch64,distro=macOS,release=${release},release-tier=${releaseTier(release)},tart=true"`,
`tags="queue=${config.queue},os=darwin,arch=aarch64,distro=macOS,release=${release},release-tier=${darwinReleaseTier(release)},tart=true"`,
`hooks-path="${hooks}"`,
`build-path="${builds}"`,
`spawn=${spawn}`,
`spawn=${workers}`,
`cancel-grace-period=30`,
"",
].join("\n"),
Expand All @@ -71,11 +85,12 @@ export async function installTartAgent({ release, spawn }: TartAgentOptions): Pr
}),
);

const tartAsCiUser = `${asCiUser.join(" ")} ${config.tart.bin}`;
const nightly = [
`PATH=${path}`,
`launchctl bootout gui/${uid}/${agentLabel}`,
"sleep 40",
`for vm in $(sudo -u ${user} tart list --source local --quiet | grep '^bk-'); do sudo -u ${user} tart delete $vm; done`,
`for vm in $(${tartAsCiUser} list --source local --quiet | grep '^bk-'); do ${tartAsCiUser} delete $vm; done`,
`rm -rf ${builds}/* /tmp/bk-*.log`,
"shutdown -r now",
].join("; ");
Expand All @@ -89,28 +104,33 @@ export async function installTartAgent({ release, spawn }: TartAgentOptions): Pr
}),
);

await $`sudo launchctl bootout system/buildkite-agent`.quiet().nothrow();
await $`sudo launchctl bootout system/${cleanupLabel}`.quiet().nothrow();
await spawn(["sudo", "launchctl", "bootout", "system/buildkite-agent"]);
await spawn(["sudo", "launchctl", "bootout", `system/${cleanupLabel}`]);
await unload(`gui/${uid}/${agentLabel}`);
Comment on lines +107 to 109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not discard launchctl failures or unload timeouts.

spawn() returns a nonzero exitCode; it does not throw. These calls discard that result. poll() also returns undefined after its final attempt. A permission error, invalid target, or busy agent can then continue to bootstrap without the failed resource or cause.

Accept only the known absent-service result. Throw all other bootout errors with stderr. If poll() expires, throw an error that identifies target and states that it did not unload before bootstrap.

As per coding guidelines, “Never swallow failures or signal success after failure; propagate I/O, syscall, cleanup, and requested-operation errors explicitly.”

Also applies to: 132-135

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/darwin-ci/lib/agent.ts` around lines 107 - 109, Update the cleanup
flow around the launchctl bootout spawn calls and unload/poll logic to inspect
and propagate failures instead of discarding results. Accept only the known
absent-service exit result; throw other bootout failures including stderr. When
poll expires and returns undefined, throw an error naming target and stating it
did not unload before bootstrap, including the analogous cleanup path.

Source: Coding guidelines

await $`sudo launchctl bootstrap gui/${uid} /Library/LaunchAgents/${agentLabel}.plist`;
await $`sudo launchctl bootstrap system /Library/LaunchDaemons/${cleanupLabel}.plist`;
await run(["sudo", "launchctl", "bootstrap", `gui/${uid}`, `/Library/LaunchAgents/${agentLabel}.plist`]);
await run(["sudo", "launchctl", "bootstrap", "system", `/Library/LaunchDaemons/${cleanupLabel}.plist`]);

await sleep(4000);
console.log(await output($`sudo tail -4 ${join(logs, "buildkite-agent.log")}`));
console.log(
(await probe(["sudo", "tail", "-4", join(logs, "buildkite-agent.log")])) ?? "(agent log not written yet)",
);
}

export async function installBareAgent(): Promise<void> {
const checkout = join(process.env.HOME!, "bun-bootstrap");
const token = await agentToken();
await $`sudo env BUILDKITE_AGENT_TOKEN=${token} PATH=${path} node scripts/agent.mjs install`.cwd(checkout);
await sleep(4000);
console.log(
await output($`tail -4 ${join(process.env.HOME!, "Library", "Logs", "buildkite-agent", "buildkite-agent.log")}`),
await runInheritOrThrow(
["sudo", "env", `PATH=${path}`, `BUILDKITE_AGENT_TOKEN=${token}`, "node", "scripts/agent.mjs", "install"],
{
cwd: bootstrapCheckout,
},
);
await sleep(4000);
const log = join(process.env.HOME!, "Library", "Logs", "buildkite-agent", "buildkite-agent.log");
console.log((await probe(["tail", "-4", log])) ?? "(agent log not written yet)");
}

// bootout returns before a busy agent has finished its cancel grace period, and bootstrap fails with EIO until it has
async function unload(target: string): Promise<void> {
await $`sudo launchctl bootout ${target}`.quiet().nothrow();
await poll(30, 2000, async () => ((await succeeds($`sudo launchctl print ${target}`)) ? undefined : true));
await spawn(["sudo", "launchctl", "bootout", target]);
await poll(30, 2000, async () => ((await succeeds(["sudo", "launchctl", "print", target])) ? undefined : true));
}
Loading
Loading