mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 01:42:23 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
822d7fec0b | ||
|
|
e29bf0d0d1 | ||
|
|
d38de8455e |
@@ -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
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user