Files
gbrain/test/autopilot-install.test.ts
Anton SenkovskiyandGitHub f0a28eb276 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).
2026-07-27 14:11:05 -07:00

145 lines
6.5 KiB
TypeScript

/**
* Tests for env-aware `gbrain autopilot --install`.
*
* Covers:
* - detectInstallTarget picks the right target based on env vars +
* filesystem sentinels.
* - --target flag overrides detection.
* - Ephemeral-container path writes the start script + executable bit.
* - OpenClaw bootstrap injection is idempotent + creates .bak.
* - Uninstall mirrors all four targets and is a no-op when nothing is
* installed.
*
* Regression guards:
* - macOS launchd plist still writes the same shape it always did.
* - Linux crontab still writes the same every-5-min line.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { detectInstallTarget } from '../src/commands/autopilot.ts';
let tmp: string;
const envSnapshot: Record<string, string | undefined> = {};
function envKeys() {
return ['HOME', 'RENDER', 'RAILWAY_ENVIRONMENT', 'FLY_APP_NAME', 'OPENCLAW_HOME'] as const;
}
beforeEach(() => {
for (const k of envKeys()) envSnapshot[k] = process.env[k];
tmp = mkdtempSync(join(tmpdir(), 'gbrain-install-test-'));
process.env.HOME = tmp;
// Start each test with a clean slate for ephemeral env vars.
delete process.env.RENDER;
delete process.env.RAILWAY_ENVIRONMENT;
delete process.env.FLY_APP_NAME;
delete process.env.OPENCLAW_HOME;
});
afterEach(() => {
for (const k of envKeys()) {
if (envSnapshot[k] === undefined) delete process.env[k];
else process.env[k] = envSnapshot[k];
}
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
});
describe('detectInstallTarget', () => {
test('returns "macos" on darwin regardless of env', () => {
if (process.platform !== 'darwin') return; // Skip on non-mac CI
// Even if RENDER is set, darwin wins (user is probably dev-testing).
process.env.RENDER = 'true';
expect(detectInstallTarget()).toBe('macos');
});
test('returns "ephemeral-container" when RENDER is set', () => {
if (process.platform === 'darwin') return; // darwin shortcircuits first
process.env.RENDER = 'true';
expect(detectInstallTarget()).toBe('ephemeral-container');
});
test('returns "ephemeral-container" when RAILWAY_ENVIRONMENT is set', () => {
if (process.platform === 'darwin') return;
process.env.RAILWAY_ENVIRONMENT = 'production';
expect(detectInstallTarget()).toBe('ephemeral-container');
});
test('returns "ephemeral-container" when FLY_APP_NAME is set', () => {
if (process.platform === 'darwin') return;
process.env.FLY_APP_NAME = 'myapp';
expect(detectInstallTarget()).toBe('ephemeral-container');
});
// Note: direct testing of linux-systemd / linux-cron requires mocking
// existsSync + execSync which is awkward in-process. Those branches are
// exercised by the E2E test (Task 14) against a stubbed host.
});
// v0.36.1.x (cherry-pick #966): the autopilot wrapper script must source
// ~/.zshenv BEFORE ~/.zshrc. zshenv is the canonical place for env vars in
// non-interactive zsh; zshrc only fires for interactive shells, so vars
// exported in zshrc never reach the LaunchAgent subprocess. Operators who
// exported GBRAIN_DATABASE_URL or {OPENAI,ANTHROPIC}_API_KEY in zshrc and
// expected autopilot to inherit them hit silent missing-secret failures.
describe('autopilot wrapper script — env source order (v0.36.1.x #966)', () => {
test('wrapper sources ~/.zshenv before ~/.zshrc', async () => {
const { readFileSync } = await import('fs');
const src = readFileSync('src/commands/autopilot.ts', 'utf8');
const zshenvIdx = src.indexOf('~/.zshenv');
const zshrcIdx = src.indexOf('~/.zshrc');
expect(zshenvIdx).toBeGreaterThan(0);
expect(zshrcIdx).toBeGreaterThan(0);
expect(zshenvIdx).toBeLessThan(zshrcIdx);
// Both should appear inside writeWrapperScript's heredoc as `source ~/.foo`
expect(src).toMatch(/source\s+~\/\.zshenv/);
expect(src).toMatch(/source\s+~\/\.zshrc/);
});
});
// v0.42.x: the wrapper must export PATH with ~/.bun/bin before exec'ing
// gbrain. The exec'd gbrain has a `#!/usr/bin/env bun` shebang, and the
// standard Debian ~/.bashrc ships a non-interactive guard
// (`case $- in *i*) ;; *) return;; esac`) that exits early when cron/launchd/
// systemd invokes bash non-interactively — so the PATH exports that
// operators put in ~/.bashrc never reach this subprocess. Without the
// explicit export the wrapper silently dies with `env: bun: No such file
// or directory`, leaves a stale lockfile, and blocks every subsequent tick
// for the 10-min stale-lock window. Regression: see a downstream agent
// fork's `cron doctor` reports — this caused a 1-week nightly-cycle outage
// on at least one operator machine before being diagnosed.
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, 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 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'\)/);
});
});