fix(autopilot): derive bun runtime dir for cron PATH; detect wrapper in --status (#3397)

Two robustness fixes to `gbrain autopilot --install`/`--status`, hardening #3305.

1. Universal bun PATH (extends #3305). The install-generated wrapper
   (~/.gbrain/autopilot-run.sh) execs the `#!/usr/bin/env bun` gbrain shim, so
   bun must be on PATH under cron/systemd/launchd's minimal env. #3305 hardcodes
   `$HOME/.bun/bin`, which only covers the default bun.sh installer. Hosts where
   bun lives elsewhere (Homebrew, npm -g, Docker /usr/local/bin, custom
   BUN_INSTALL, nix) still die with `env: bun: No such file or directory`,
   leaving a stale lock that stalls the nightly cycle. Fix: bake the dir of the
   actually-running bun (dirname(process.execPath)) onto PATH at install time,
   ~/.bun/bin kept as fallback, single-quote-escaped, empty execPath guarded.

2. `--status` false negative. showStatus() checked crontab.includes('gbrain
   autopilot'), but --install writes a line calling the wrapper
   `.../autopilot-run.sh` — no such substring. So `--status` reported
   installed:false on every wrapper-based Linux host. Fix: also match
   'autopilot-run.sh'.

Tests: test/autopilot-install.test.ts — universal-form + runtime-derivation +
wrapper-detection assertions (fail-before/pass-after verified).
This commit is contained in:
Anton Senkovskiy
2026-07-27 14:11:05 -07:00
committed by GitHub
parent 5ecab70a21
commit f0a28eb276
2 changed files with 41 additions and 9 deletions
+21 -6
View File
@@ -19,7 +19,7 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, utimesSync, unlinkSync, chmodSync } from 'fs';
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
import { join } from 'path';
import { join, dirname } from 'path';
import { execSync } from 'child_process';
import type { BrainEngine } from '../core/engine.ts';
import { loadPreferences } from '../core/preferences.ts';
@@ -1312,6 +1312,17 @@ function writeWrapperScript(repoPath: string): string {
const gbrainPath = resolveGbrainCliPath();
const safeRepoPath = repoPath.replace(/'/g, "'\\''");
const safeGbrainPath = gbrainPath.replace(/'/g, "'\\''");
// Bake the dir of the bun runtime actually executing this install onto PATH,
// so the wrapper finds bun wherever it lives — Homebrew (/opt/homebrew/bin),
// npm -g, Docker (/usr/local/bin), a custom BUN_INSTALL, or nix — not just
// ~/.bun/bin (which #3305 hardcoded, covering only the default bun.sh installer).
// dirname('') === '.', so guard the degenerate/empty case — otherwise a missing
// execPath would prepend '.' (cwd) onto a cron PATH. Empty prefix falls back to
// the #3305 behavior exactly.
const runtimeDir = dirname(process.execPath || '');
const runtimePathPrefix = runtimeDir && runtimeDir !== '.'
? `'${runtimeDir.replace(/'/g, "'\\''")}':`
: '';
const wrapper = `#!/bin/bash
# Auto-generated by gbrain autopilot --install
# Sources shell profile for API keys, then runs autopilot.
@@ -1326,10 +1337,11 @@ source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null || true
# cron/systemd/launchd — so its PATH exports never reach this subprocess.
# Without bun on PATH, the exec'd gbrain (a \`#!/usr/bin/env bun\` script) fails
# silently with "env: bun: No such file or directory" and leaves a stale
# lockfile that blocks every subsequent tick. Prepending ~/.bun/bin here
# keeps the wrapper self-contained regardless of which init file the OS
# loaded.
export PATH="$HOME/.bun/bin:$PATH"
# lockfile that blocks every subsequent tick. Prepending the running bun's own
# dir (derived from process.execPath at install time), with ~/.bun/bin kept as a
# fallback, keeps the wrapper self-contained regardless of where bun is installed
# or which init file the OS loaded.
export PATH=${runtimePathPrefix}"$HOME/.bun/bin:$PATH"
exec '${safeGbrainPath}' autopilot --repo '${safeRepoPath}'
`;
writeFileSync(wrapperPath, wrapper, { mode: 0o755 });
@@ -1756,7 +1768,10 @@ function showStatus(json: boolean) {
} else {
try {
const crontab = execSync('crontab -l 2>/dev/null || true', { encoding: 'utf-8' });
installed = crontab.includes('gbrain autopilot');
// The installed cron line invokes the generated wrapper (…/autopilot-run.sh);
// older installs called `gbrain autopilot` directly. Match either so status
// isn't a false negative after the wrapper indirection landed.
installed = crontab.includes('autopilot-run.sh') || crontab.includes('gbrain autopilot');
} catch { /* no crontab */ }
}
+20 -3
View File
@@ -115,13 +115,30 @@ describe('autopilot wrapper script — bun PATH export (v0.42.x regression)', ()
test('wrapper exports ~/.bun/bin onto PATH before the exec', async () => {
const { readFileSync } = await import('fs');
const src = readFileSync('src/commands/autopilot.ts', 'utf8');
// The export line must appear inside the writeWrapperScript heredoc.
expect(src).toMatch(/export\s+PATH="\$HOME\/\.bun\/bin:\$PATH"/);
// The export line must appear inside the writeWrapperScript heredoc, now
// prefixed with the runtime dir derived at install time (universal), with
// ~/.bun/bin retained as a fallback.
expect(src).toMatch(/export PATH=\$\{runtimePathPrefix\}"\$HOME\/\.bun\/bin:\$PATH"/);
// The runtime dir is derived from the actually-running bun (covers Homebrew /
// npm -g / Docker / custom BUN_INSTALL / nix), not hardcoded to ~/.bun/bin.
expect(src).toMatch(/const runtimeDir = dirname\(process\.execPath/);
// The export must precede the exec line, otherwise env never sees it.
const exportIdx = src.search(/export\s+PATH="\$HOME\/\.bun\/bin/);
const exportIdx = src.search(/export PATH=\$\{runtimePathPrefix\}/);
const execIdx = src.search(/exec\s+'\${safeGbrainPath}'/);
expect(exportIdx).toBeGreaterThan(0);
expect(execIdx).toBeGreaterThan(0);
expect(exportIdx).toBeLessThan(execIdx);
});
});
// Status detection must recognize the wrapper-based cron line that --install
// actually writes (…/autopilot-run.sh), not just the legacy `gbrain autopilot`
// invocation — otherwise `--status` reports installed:false on every Linux host
// that installed via the wrapper indirection.
describe('autopilot showStatus — wrapper-path detection', () => {
test('status detects the autopilot-run.sh wrapper line', async () => {
const { readFileSync } = await import('fs');
const src = readFileSync('src/commands/autopilot.ts', 'utf8');
expect(src).toMatch(/crontab\.includes\('autopilot-run\.sh'\)/);
});
});