Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Fable 5 822d7fec0b fix(autopilot): boot warning must not recommend the DB-plane config set path
`gbrain config set anthropic_api_key` writes the config TABLE via
engine.setConfig; buildGatewayConfig only folds file-plane keys
(~/.gbrain/config.json) + env, so following the warning's advice left
isAvailable('chat') false and the warning firing forever (verified
empirically on a scratch brain). Point at the two paths that actually
resolve: ~/.gbrain/env (sourced by the daemon wrapper) and the
config.json file plane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:47:19 -07:00
Garry TanandClaude Fable 5 e29bf0d0d1 test: route env mutations through withEnv() in providers-gateway-env test
check:test-isolation rule R1 flagged raw process.env mutation in the new
test file. Rewritten to use the canonical withEnv() helper; temp-dir setup
and cleanup move inside the test with try/finally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:49:52 -07:00
Garry TanandClaude Fable 5 d38de8455e fix(gateway): fold file-plane keys into providers key checks; autopilot wrapper sources ~/.gbrain/env + chat-unavailable boot warning
- providers env/explain now read gatewayEnv() (buildGatewayConfig-folded env)
  instead of bare process.env, so a key living only in ~/.gbrain/config.json
  isn't reported as missing (#2728 residual; list already fixed, test --model
  already fixed by #2863).
- autopilot --install wrapper also sources ~/.gbrain/env (after the shell
  profiles) so daemon shells get a deterministic gbrain-owned env plane, and
  autopilot logs one boot-time stderr warning when no chat provider resolves
  so LLM-phase no-ops are visible (#2608).

Fixes #2728
Fixes #2608

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:46:17 -07:00
4 changed files with 141 additions and 16 deletions
+35
View File
@@ -356,6 +356,23 @@ async function attemptAutopilotSelfUpgrade(
}
}
/**
* #2608 — pure function (test seam, same pattern as generateLaunchdPlist):
* the boot-time warning emitted when no chat provider is available, so the
* silent no-op of every LLM phase is visible in the daemon log.
*/
export function chatBootWarning(chatAvailable: boolean): string | null {
if (chatAvailable) return null;
return (
'[autopilot] WARNING: no chat provider available — LLM phases (extract, dream, enrich) will no-op. ' +
// NOT `gbrain config set anthropic_api_key`: that writes the DB plane,
// which the gateway never reads (isAvailable('chat') stays false) — the
// split-brain class build-gateway-config.ts documents. File plane or env
// are the two paths that actually resolve.
'Export ANTHROPIC_API_KEY in ~/.gbrain/env (sourced by the daemon wrapper) or set "anthropic_api_key" in ~/.gbrain/config.json.'
);
}
export async function runAutopilot(engine: BrainEngine, args: string[]) {
if (args.includes('--help') || args.includes('-h')) {
console.log(
@@ -416,6 +433,18 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
console.log(`Autopilot starting. Repo: ${repoPath}, interval: ${baseInterval}s`);
// #2608: LLM phases (extract-events, dream synthesis, enrich) gate on
// isAvailable('chat') and silently no-op when no chat provider resolves —
// the classic symptom of a daemon shell that never sourced the API keys.
// One loud boot-time stderr line makes that failure mode visible in the
// daemon log instead of manifesting as "autopilot runs but nothing gets
// extracted".
try {
const { isAvailable } = await import('../core/ai/gateway.ts');
const warn = chatBootWarning(isAvailable('chat'));
if (warn) console.error(warn);
} catch { /* diagnostic only — never blocks the loop */ }
// Mode resolution: Minions dispatch when the user has opted in AND the
// worker daemon can actually run (Postgres only; PGLite's exclusive file
// lock blocks a separate worker process).
@@ -1186,6 +1215,12 @@ function writeWrapperScript(repoPath: string): string {
# OPENAI/ANTHROPIC keys exported in zshenv reach autopilot.
[ -f ~/.zshenv ] && source ~/.zshenv 2>/dev/null
source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null || true
# gbrain-owned env file (#2608): daemon shells are non-interactive, so
# zshrc-only exports never arrive here (and many zshrc/bashrc files guard
# against non-interactive sourcing). ~/.gbrain/env is the deterministic
# place to put API keys / GBRAIN_* vars for the daemon. Sourced last so it
# wins over anything the profiles set.
[ -f ~/.gbrain/env ] && source ~/.gbrain/env 2>/dev/null
exec '${safeGbrainPath}' autopilot --repo '${safeRepoPath}'
`;
writeFileSync(wrapperPath, wrapper, { mode: 0o755 });
+32 -15
View File
@@ -49,6 +49,19 @@ function configureFromEnv(): void {
configureGateway({ env: { ...process.env } });
}
/**
* The env the gateway actually sees: file-plane API keys (openai_api_key,
* anthropic_api_key, zeroentropy_api_key, openrouter_api_key in
* ~/.gbrain/config.json) folded under process.env via buildGatewayConfig.
* Every `providers` key check reads this instead of bare process.env so a
* config.json-keyed provider isn't reported as missing (#2728). Falls back
* to bare process.env pre-init (no config file yet).
*/
export function gatewayEnv(): NodeJS.ProcessEnv {
const cfg = loadConfig();
return cfg ? buildGatewayConfig(cfg).env : process.env;
}
export function envReady(recipe: Recipe, env: NodeJS.ProcessEnv = process.env): boolean {
const required = recipe.auth_env?.required ?? [];
if (required.length === 0) return true; // e.g. local Ollama
@@ -144,9 +157,7 @@ function runList(_args: string[]): void {
// Same env the gateway actually sees (file-plane keys folded in), not bare
// process.env — keeps this table's STATUS column honest with what
// `providers test` (and the real init/gateway path) would report.
const cfg = loadConfig();
const env = cfg ? buildGatewayConfig(cfg).env : process.env;
console.log(formatRecipeTable(listRecipes(), env));
console.log(formatRecipeTable(listRecipes(), gatewayEnv()));
}
async function runTest(args: string[]): Promise<void> {
@@ -278,12 +289,15 @@ function runEnv(args: string[]): void {
}
console.log(`${recipe.name} (${recipe.id})`);
console.log('');
// Folded env, not bare process.env — a key living only in
// ~/.gbrain/config.json must still show '✓ set' here (#2728).
const env = gatewayEnv();
const required = recipe.auth_env?.required ?? [];
const optional = recipe.auth_env?.optional ?? [];
if (required.length > 0) {
console.log('Required:');
for (const k of required) {
const set = !!process.env[k];
const set = !!env[k];
console.log(` ${k.padEnd(32)} ${set ? '✓ set' : '✗ not set'}`);
}
} else {
@@ -292,7 +306,7 @@ function runEnv(args: string[]): void {
if (optional.length > 0) {
console.log('\nOptional:');
for (const k of optional) {
const set = !!process.env[k];
const set = !!env[k];
console.log(` ${k.padEnd(32)} ${set ? '✓ set' : '✗ not set'}`);
}
}
@@ -308,14 +322,17 @@ async function runExplain(args: string[]): Promise<void> {
const asJson = args.includes('--json') || args.includes('-j');
const recipes = listRecipes();
// Folded env (file-plane keys included), matching what the gateway and
// `providers test` actually resolve against (#2728).
const env = gatewayEnv();
const env_detected = {
OPENAI_API_KEY: !!process.env.OPENAI_API_KEY,
GOOGLE_GENERATIVE_AI_API_KEY: !!process.env.GOOGLE_GENERATIVE_AI_API_KEY,
ANTHROPIC_API_KEY: !!process.env.ANTHROPIC_API_KEY,
VOYAGE_API_KEY: !!process.env.VOYAGE_API_KEY,
DEEPSEEK_API_KEY: !!process.env.DEEPSEEK_API_KEY,
GROQ_API_KEY: !!process.env.GROQ_API_KEY,
TOGETHER_API_KEY: !!process.env.TOGETHER_API_KEY,
OPENAI_API_KEY: !!env.OPENAI_API_KEY,
GOOGLE_GENERATIVE_AI_API_KEY: !!env.GOOGLE_GENERATIVE_AI_API_KEY,
ANTHROPIC_API_KEY: !!env.ANTHROPIC_API_KEY,
VOYAGE_API_KEY: !!env.VOYAGE_API_KEY,
DEEPSEEK_API_KEY: !!env.DEEPSEEK_API_KEY,
GROQ_API_KEY: !!env.GROQ_API_KEY,
TOGETHER_API_KEY: !!env.TOGETHER_API_KEY,
};
// Parallel probes for local providers (1s timeout each)
@@ -332,7 +349,7 @@ async function runExplain(args: string[]): Promise<void> {
dims: m.default_dims,
cost_per_1m_tokens_usd: m.cost_per_1m_tokens_usd,
price_last_verified: m.price_last_verified,
env_ready: envReady(r) || (r.id === 'ollama' && ollama.models_endpoint_valid === true),
env_ready: envReady(r, env) || (r.id === 'ollama' && ollama.models_endpoint_valid === true),
tier: r.tier,
pros: prosFor(r, 'embedding'),
cons: consFor(r),
@@ -346,7 +363,7 @@ async function runExplain(args: string[]): Promise<void> {
model: m.models[0],
cost_per_1m_tokens_usd: m.cost_per_1m_tokens_usd,
price_last_verified: m.price_last_verified,
env_ready: envReady(r),
env_ready: envReady(r, env),
tier: r.tier,
pros: prosFor(r, 'expansion'),
cons: consFor(r),
@@ -361,7 +378,7 @@ async function runExplain(args: string[]): Promise<void> {
cost_per_1m_input_usd: m.cost_per_1m_input_usd,
cost_per_1m_output_usd: m.cost_per_1m_output_usd,
price_last_verified: m.price_last_verified,
env_ready: envReady(r),
env_ready: envReady(r, env),
tier: r.tier,
pros: prosFor(r, 'chat'),
cons: consFor(r),
+32 -1
View File
@@ -20,7 +20,7 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync
import { join } from 'path';
import { tmpdir } from 'os';
import { detectInstallTarget } from '../src/commands/autopilot.ts';
import { detectInstallTarget, chatBootWarning } from '../src/commands/autopilot.ts';
let tmp: string;
const envSnapshot: Record<string, string | undefined> = {};
@@ -98,4 +98,35 @@ describe('autopilot wrapper script — env source order (v0.36.1.x #966)', () =>
expect(src).toMatch(/source\s+~\/\.zshenv/);
expect(src).toMatch(/source\s+~\/\.zshrc/);
});
// #2608: shell profiles are unreliable in non-interactive daemon shells
// (zshrc-only exports never arrive; many profiles guard against
// non-interactive sourcing). The wrapper must also source the
// gbrain-owned ~/.gbrain/env, AFTER the profiles so it wins.
test('wrapper sources ~/.gbrain/env after the shell profiles (#2608)', async () => {
const { readFileSync } = await import('fs');
const src = readFileSync('src/commands/autopilot.ts', 'utf8');
expect(src).toMatch(/\[ -f ~\/\.gbrain\/env \] && source ~\/\.gbrain\/env/);
const zshrcIdx = src.indexOf('source ~/.zshrc');
const gbrainEnvIdx = src.indexOf('source ~/.gbrain/env');
expect(zshrcIdx).toBeGreaterThan(0);
expect(gbrainEnvIdx).toBeGreaterThan(zshrcIdx);
});
});
// #2608: when the daemon boots without a resolvable chat provider, every
// LLM phase (extract-events, dream, enrich) silently no-ops. The boot-time
// warning is the visibility fix; pure function so the wiring is pinnable
// without spinning up a full autopilot loop.
describe('chatBootWarning (#2608)', () => {
test('warns loudly when chat is unavailable', () => {
const warn = chatBootWarning(false);
expect(warn).toContain('[autopilot] WARNING');
expect(warn).toContain('no chat provider');
expect(warn).toContain('~/.gbrain/env');
});
test('silent when chat is available', () => {
expect(chatBootWarning(true)).toBeNull();
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* #2728 — `gbrain providers env/explain/list` key checks must read the SAME
* env the gateway resolves against (file-plane keys from ~/.gbrain/config.json
* folded in via buildGatewayConfig), not bare process.env. A user whose only
* OPENAI_API_KEY lives in config.json was told the provider is missing while
* the real gateway path worked fine.
*
* Env mutations go through withEnv() (test-isolation rule R1).
*/
import { describe, test, expect } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { withEnv } from './helpers/with-env.ts';
import { gatewayEnv } from '../src/commands/providers.ts';
describe('gatewayEnv (#2728)', () => {
test('folds file-plane openai_api_key into the env used for key checks', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-providers-env-'));
mkdirSync(join(dir, '.gbrain'), { recursive: true });
writeFileSync(
join(dir, '.gbrain', 'config.json'),
JSON.stringify({
engine: 'pglite',
database_path: join(dir, '.gbrain', 'brain'),
openai_api_key: 'sk-file-plane-only',
}),
);
try {
await withEnv({ GBRAIN_HOME: dir, OPENAI_API_KEY: undefined }, () => {
// Sanity: the key is NOT in process.env — bare process.env checks
// (the pre-fix behavior of `providers env` / `explain`) would miss it.
expect(process.env.OPENAI_API_KEY).toBeUndefined();
const env = gatewayEnv();
expect(env.OPENAI_API_KEY).toBe('sk-file-plane-only');
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});