mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
The wrapper script that 'gbrain autopilot --install' writes to ~/.gbrain/autopilot-run.sh sources ~/.bashrc to inherit PATH for the exec'd gbrain binary (which has a '#!/usr/bin/env bun' shebang). The standard Debian/Ubuntu ~/.bashrc ships a non-interactive guard that returns early when bash is launched non-interactively (cron, launchd, systemd) — so PATH exports operators add to ~/.bashrc never reach the wrapper subprocess. The result: the wrapper dies silently with 'env: bun: No such file or directory', leaves a stale lockfile, and every subsequent cron tick hits the lockfile and bails. The nightly dream cycle hangs waiting on a worker that never comes back, and the wrapper's own 10-min stale-lock window is the only thing that can recover it. This bites every operator whose bashrc is the standard distro default (which is the default), and there is no warning at install time. Fix: prepend ~/.bun/bin to PATH directly in the wrapper, so it is self-contained regardless of which init file the OS loaded. Add a regression test alongside the existing zshenv/zshrc source-order test (v0.36.1.x #966) so this class of bug stays caught.
128 lines
5.5 KiB
TypeScript
128 lines
5.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 Hermes `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.
|
|
expect(src).toMatch(/export\s+PATH="\$HOME\/\.bun\/bin:\$PATH"/);
|
|
// The export must precede the exec line, otherwise env never sees it.
|
|
const exportIdx = src.search(/export\s+PATH="\$HOME\/\.bun\/bin/);
|
|
const execIdx = src.search(/exec\s+'\${safeGbrainPath}'/);
|
|
expect(exportIdx).toBeGreaterThan(0);
|
|
expect(execIdx).toBeGreaterThan(0);
|
|
expect(exportIdx).toBeLessThan(execIdx);
|
|
});
|
|
});
|