Compare commits

...
Author SHA1 Message Date
967bff6cda fix(auth): expose OAuth source grants in whoami (takeover of #3279)
Rebase of #3279 onto current master: the whoami oauth shape gains
source_id (AuthInfo.sourceId, null when absent) and federated_read
(AuthInfo.allowedSources, [] when absent) — read-only self-introspection
that widens no grant. Re-applied against the post-#3091 description
string (stdio transport shape preserved) and merged the grant tests
into the current whoami.test.ts alongside the stdio cases.

Co-authored-by: boundless-forest <boundless-forest@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:38:40 -07:00
ca47c054b8 fix(gateway): brainstorm/propose_takes model-config takeovers — configured-model cost preview, judge config key, provider-probe skip, narrow page projection (#3120)
* fix(cycle): propose_takes skips cleanly when the chat provider is unavailable + narrow page projection

Takeover of PR #1979 by @shawnduggan. The original PR gated on a
hardcoded ANTHROPIC_API_KEY heuristic (modelNeedsAnthropicKey defaulting
to true), which master deliberately removed elsewhere — it misclassified
non-Anthropic stacks and fought the tier-config model resolution. This
lands the intent the master-blessed way: probe the RESOLVED chat model
(opts.model ?? getChatModel()) via probeChatModel — same semantics as
patterns.ts / think/index.ts — and skip the phase cheaply when the
provider can't run. Injected extractors are never gated.

Also keeps the PR's uncontested half: load proposal candidates with a
narrow projection (slug, source_id, compiled_truth) instead of
listPages' SELECT p.*, preserving sourceIds > sourceId scope precedence
and updated_desc ordering.

Co-authored-by: shawnduggan <shawnduggan@users.noreply.github.com>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(brainstorm): price cost preview against the configured chat model + models.brainstorm.judge config key

Takeover of PR #1855 by @starm2010, shrunk to the brainstorm-only
portion (the cycle-phase hunks are superseded by the resolveModel-in-
phase approach already on master). The cost preview + hard cost ceiling
previously always priced anthropic:claude-sonnet-4-6 even when the
configured chat_model (which the gateway actually runs) was something
else; modelStr now resolves override → config.chat_model → fallback.
The judge phase honors a new models.brainstorm.judge config key when no
--judge-model flag is passed, resolved in the orchestrator so every
caller (brainstorm, lsd, eval-brainstorm) benefits.

Co-authored-by: starm2010 <starm2010@users.noreply.github.com>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): use withEnv()/emptyHome() in propose-takes no-key tests

check:test-isolation R1 flagged direct process.env mutation in the two
new no-key tests. Swap the hand-rolled save/mutate/restore for the
canonical withEnv() helper (+ emptyHome() for the hermetic GBRAIN_HOME).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:29:09 -07:00
Garry Tan 97df1e78b7 Revert "fix(init): explicit --embedding-model overrides persisted --no-embedding sentinel (#3138)"
This reverts commit df22c81996.
2026-07-23 15:26:33 -07:00
Garry Tan 1392243d3b Revert "fix(jobs/autopilot): --install interval persistence, --lock-duration flag, dead-jobs doctor check, deployment-shape docs (#3129)"
This reverts commit 8345abce42.
2026-07-23 15:26:33 -07:00
8345abce42 fix(jobs/autopilot): --install interval persistence, --lock-duration flag, dead-jobs doctor check, deployment-shape docs (#3129)
* fix(jobs/autopilot): interval persistence, --lock-duration flag, dead-jobs doctor check, deployment-shape docs

Four backlog items in the jobs/autopilot workers, locks & installers area:

- #2794: `gbrain autopilot --install` silently dropped `--interval`. The
  installer now parses + validates it, persists it to config
  (autopilot.interval), and threads it into the wrapper's exec line; a
  later flag-less --install regenerates the wrapper from the persisted
  value, and the daemon run path falls back to the same config key.

- #1014: new `--lock-duration MS` flag (env: GBRAIN_LOCK_DURATION) on
  `gbrain jobs work` and `gbrain jobs supervisor` to tune the worker
  stall-lock window (and so the lockDuration x max_stalled wall-clock
  dead-letter cap). Validated like --health-interval (integer >= 1000ms);
  the supervisor propagates it to the spawned worker via buildWorkerArgs;
  shown in the worker startup banner.

- Takeover of PR #1185 (@ethanbeard): `gbrain integrations doctor` now
  surfaces dead minion jobs as a cross-cutting [queue] check. Reworked
  from the original: consumes a new machine-readable `gbrain jobs list
  --json` surface instead of screen-scraping the human table (long job
  names shift the columns), and scopes to a 24h finished_at window so one
  ancient dead job can't flag ISSUES forever (parity with main doctor's
  queue checks).

- #631: documented the production deployment shape for autopilot vs jobs
  supervisor in docs/guides/minions-deployment.md — recommend the
  `autopilot --no-worker` + `jobs supervisor` split, warn against running
  both worker lanes, and cross-link the --no-worker liveness probe.

Co-authored-by: ethanbeard <ethanbeard@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(autopilot): make #2794 wrapper-script tests hermetic — fake gbrain on PATH

writeWrapperScript calls resolveGbrainCliPath(), which shells out to
`which gbrain` and throws on CI runners where no gbrain binary is
installed. The two new --interval threading tests failed only in CI
(dev machines have gbrain on PATH). Prepend a fake executable to PATH
for the describe block so resolution is deterministic everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: ethanbeard <ethanbeard@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:06:17 -07:00
11 changed files with 332 additions and 156 deletions
File diff suppressed because one or more lines are too long
+3 -20
View File
@@ -249,13 +249,6 @@ async function resolveAIOptions(opts: ResolveAIOptionsArgs): Promise<ResolvedAIO
// --- Tier 1+2: explicit flags ---------------------------------------------
// #2301: an explicit embedding flag on THIS invocation overrides the
// persisted deferred-setup sentinel above. Without this, a stale
// `embedding_disabled: true` in config.json made every re-init defer
// embedding — including `gbrain init --embedding-model ...`, the exact
// recovery path the deferred-setup message tells users to take.
if (verbose || shorthand) delete out.noEmbedding;
if (verbose) {
out.embedding_model = verbose;
} else if (shorthand) {
@@ -442,7 +435,7 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
console.error(' gbrain init --pglite --embedding-model openai:text-embedding-3-large');
console.error('');
console.error('Or defer setup: gbrain init --pglite --no-embedding');
console.error(' (you can configure later with `gbrain init --force --embedding-model <provider>:<model>`)');
console.error(' (you can configure later with `gbrain config set embedding_model <id>`)');
// D13: surface near-miss env vars (e.g. OPENAPI_API_KEY → OPENAI_API_KEY).
if (typos.length > 0) {
console.error('');
@@ -840,7 +833,7 @@ async function initPGLite(opts: {
let resolvedModel: string | undefined;
if (opts.aiOpts?.noEmbedding) {
// D9 deferred-setup mode: skip preflight, no model/dim resolved.
console.log(` --no-embedding: deferred setup — run \`gbrain init --force --embedding-model <provider>:<model>\` before import`);
console.log(` --no-embedding: deferred setup — configure with \`gbrain config set embedding_model <id>\` before import`);
} else if (opts.aiOpts?.embedding_model) {
const { resolveSchemaEmbeddingDim } = await import('../core/embedding-dim-check.ts');
const pre = resolveSchemaEmbeddingDim({
@@ -979,12 +972,6 @@ async function initPGLite(opts: {
// unless explicitly overridden by --schema-pack on re-init.
...(opts.schemaPack ? { schema_pack: opts.schemaPack } : {}),
};
// #2301: a resolved embedding model supersedes any stale deferred-setup
// sentinel carried over via ...existingFile — otherwise the sentinel
// re-defers embedding on every future init/embed forever.
if (!opts.aiOpts?.noEmbedding && resolvedModel && resolvedDim) {
delete config.embedding_disabled;
}
// PR1: new installs publish their skill catalog over MCP by default
// (existing config wins on re-init, so a prior opt-out is preserved).
config.mcp = { publish_skills: true, ...(config.mcp ?? {}) };
@@ -1069,7 +1056,7 @@ async function initPostgres(opts: {
let resolvedDim: number | undefined;
let resolvedModel: string | undefined;
if (opts.aiOpts?.noEmbedding) {
console.log(` --no-embedding: deferred setup — run \`gbrain init --force --embedding-model <provider>:<model>\` before import`);
console.log(` --no-embedding: deferred setup — configure with \`gbrain config set embedding_model <id>\` before import`);
} else if (opts.aiOpts?.embedding_model) {
const { resolveSchemaEmbeddingDim } = await import('../core/embedding-dim-check.ts');
const pre = resolveSchemaEmbeddingDim({
@@ -1233,10 +1220,6 @@ async function initPostgres(opts: {
// v0.42 (T17): same schema_pack default as PGLite path.
...(opts.schemaPack ? { schema_pack: opts.schemaPack } : {}),
};
// #2301: same stale-sentinel drop as the PGLite path above.
if (!opts.aiOpts?.noEmbedding && resolvedModel && resolvedDim) {
delete config.embedding_disabled;
}
// PR1: new installs publish their skill catalog over MCP by default
// (existing config wins on re-init, so a prior opt-out is preserved).
config.mcp = { publish_skills: true, ...(config.mcp ?? {}) };
+40 -5
View File
@@ -486,9 +486,44 @@ const DEFAULT_PARALLELISM = 4;
* src/core/errors.ts (the v0.19.0 envelope every new agent-facing
* surface uses) rather than introducing a new BrainstormError class.
*/
/** File-config slice the orchestrator reads (see loadConfig in core/config.ts). */
export interface BrainstormRunConfig {
embedding_model?: string;
chat_model?: string;
emotional_weight?: { user_holder?: string };
}
/**
* Model used for the cost preview + hard cost ceiling. Mirrors what the
* gateway will actually run: explicit --model override, else the configured
* chat_model (gateway default), else the hardcoded gateway fallback. Before
* this resolved through config, a non-Sonnet chat_model got its preview
* priced against the wrong model. (Takeover of PR #1855 by @starm2010.)
*/
export function resolveBrainstormChatModel(
config: { chat_model?: string },
modelOverride?: string,
): string {
return modelOverride ?? config.chat_model ?? 'anthropic:claude-sonnet-4-6';
}
/**
* Judge-phase model precedence: --judge-model flag, else the
* `models.brainstorm.judge` config key, else undefined (falls back to
* `modelOverride` then the gateway default at the runJudge callsite).
*/
export async function resolveBrainstormJudgeModel(
engine: BrainEngine,
judgeModelFlag?: string,
): Promise<string | undefined> {
if (judgeModelFlag) return judgeModelFlag;
const configured = await engine.getConfig('models.brainstorm.judge');
return configured ?? undefined;
}
export async function runBrainstorm(
engine: BrainEngine,
config: { embedding_model?: string; emotional_weight?: { user_holder?: string } },
config: BrainstormRunConfig,
opts: BrainstormOptions
): Promise<BrainstormResult> {
// v0.39.3.0 (Phase 5, CV11+T4): outer try/catch around the orchestrator
@@ -510,7 +545,7 @@ export async function runBrainstorm(
async function runBrainstormImpl(
engine: BrainEngine,
config: { embedding_model?: string; emotional_weight?: { user_holder?: string } },
config: BrainstormRunConfig,
opts: BrainstormOptions,
): Promise<BrainstormResult> {
// v0.39.0.0 T10: install a gateway-layer BudgetTracker scope around the
@@ -530,7 +565,7 @@ async function runBrainstormImpl(
async function _runBrainstormInner(
engine: BrainEngine,
config: { embedding_model?: string; emotional_weight?: { user_holder?: string } },
config: BrainstormRunConfig,
opts: BrainstormOptions,
): Promise<BrainstormResult> {
const profile = opts.profile ?? BRAINSTORM_PROFILE;
@@ -539,7 +574,7 @@ async function _runBrainstormInner(
const embedFn = opts.embedQueryFn ?? embedQuery;
// ---- Phase 0: cost preview + TTY grace ----
const modelStr = opts.modelOverride ?? 'anthropic:claude-sonnet-4-6';
const modelStr = resolveBrainstormChatModel(config, opts.modelOverride);
const { aborted, estimate } = await previewCostAndWait({
profile,
model: modelStr,
@@ -848,7 +883,7 @@ async function _runBrainstormInner(
far_slug: i.far_slug,
}));
const judgeResult = await runJudge(profile.judge_config, judgeInput, {
modelOverride: opts.judgeModel ?? opts.modelOverride,
modelOverride: (await resolveBrainstormJudgeModel(engine, opts.judgeModel)) ?? opts.modelOverride,
chatFn: opts.chatFn,
activeBiasTags: activeBiasTags ?? undefined,
abortSignal: opts.abortSignal,
+1
View File
@@ -962,6 +962,7 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
'models.subagent',
'models.expansion',
'models.chat',
'models.brainstorm.judge',
'models.eval.longmemeval',
'facts.extraction_model',
// #2113: output-token cap for the per-turn facts extractor (default 4000).
+74 -10
View File
@@ -39,11 +39,11 @@
import { randomUUID, createHash } from 'node:crypto';
import { BaseCyclePhase, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts';
import { chat as gatewayChat, getChatModel } from '../ai/gateway.ts';
import { chat as gatewayChat, getChatModel, probeChatModel } from '../ai/gateway.ts';
import { normalizeModelId } from '../model-id.ts';
import { writeReceipt } from '../extract/receipt-writer.ts';
import { upsertExtractRollup } from '../extract/rollup-writer.ts';
import { GBrainError } from '../types.ts';
import type { Page, PageFilters } from '../types.ts';
import type { OperationContext } from '../operations.ts';
import type { BrainEngine } from '../engine.ts';
import type { PhaseStatus, CyclePhase } from '../cycle.ts';
@@ -160,6 +160,48 @@ export interface ProposeTakesResult {
warnings: string[];
}
/** Narrow projection of `pages` — the only columns this phase reads. */
interface ProposeTakesPageRow {
slug: string;
source_id: string;
compiled_truth: string | null;
}
/**
* Load proposal candidates with a narrow projection instead of
* `engine.listPages` (`SELECT p.*`). The phase only reads slug, source_id
* and compiled_truth — skipping timeline/frontmatter/title keeps large
* toasted columns out of the hot path. Scope precedence mirrors
* `sourceScopeOpts`: federated array (`sourceIds`) beats scalar
* (`sourceId`); ordering matches `PAGE_SORT_SQL.updated_desc` with an id
* tiebreak for determinism. (Takeover of PR #1979's projection by
* @shawnduggan.)
*/
async function listCandidatePages(
engine: BrainEngine,
scope: ScopedReadOpts,
limit: number,
): Promise<ProposeTakesPageRow[]> {
const where = ['deleted_at IS NULL'];
const params: unknown[] = [];
if (scope.sourceIds && scope.sourceIds.length > 0) {
params.push(scope.sourceIds);
where.push(`source_id = ANY($${params.length}::text[])`);
} else if (scope.sourceId) {
params.push(scope.sourceId);
where.push(`source_id = $${params.length}`);
}
params.push(limit);
return engine.executeRaw<ProposeTakesPageRow>(
`SELECT slug, source_id, compiled_truth
FROM pages
WHERE ${where.join(' AND ')}
ORDER BY updated_at DESC, id DESC
LIMIT $${params.length}`,
params,
);
}
/**
* Compute the content_hash key for the idempotency cache. SHA-256 of the
* page body suffices — page slug + prompt_version are separate columns in
@@ -330,6 +372,34 @@ class ProposeTakesPhase extends BaseCyclePhase {
const phaseStartMs = Date.now();
const proposalRunId = `propose-${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '')}-${randomUUID().slice(0, 8)}`;
const modelId = opts.model ?? getChatModel();
// With the default (gateway) extractor, skip cheaply when the resolved
// model's provider can't run — same probe semantics as patterns.ts /
// think/index.ts: unknown provider/model or Anthropic-without-key skips;
// other providers' auth surfaces lazily at chat() time. An injected
// extractor bypasses the gateway, so it is never gated. (Takeover of
// PR #1979's intent by @shawnduggan.)
if (!opts.extractor) {
const probe = probeChatModel(normalizeModelId(modelId));
if (!probe.ok) {
return {
summary: `propose_takes skipped: ${probe.detail}`,
details: {
reason: 'no_provider',
model: modelId,
pages_scanned: 0,
cache_hits: 0,
cache_misses: 0,
proposals_inserted: 0,
budget_exhausted: false,
warnings: [],
},
status: 'skipped',
};
}
}
const result: ProposeTakesResult = {
pages_scanned: 0,
cache_hits: 0,
@@ -340,19 +410,12 @@ class ProposeTakesPhase extends BaseCyclePhase {
};
// Load pages eligible for proposal. Source-scoped per BaseCyclePhase.
const pageFilters: PageFilters = {
...scope,
limit: pageLimit,
sort: 'updated_desc',
};
const pages: Page[] = await engine.listPages(pageFilters);
const pages = await listCandidatePages(engine, scope, pageLimit);
if (opts.reporter) {
opts.reporter.start('propose_takes.pages' as never, pages.length);
}
const modelId = opts.model ?? getChatModel();
for (const page of pages) {
// Phase deadline check. Break (not throw) so the phase returns a
// partial result with deadline_hit:true; work already banked stays.
@@ -509,4 +572,5 @@ export const __testing = {
contentHash,
hasCompleteFence,
extractExistingTakesForDedup,
listCandidatePages,
};
+3 -2
View File
@@ -71,8 +71,9 @@ export function assertEmbeddingEnabled(cfg: { embedding_disabled?: boolean } | n
throw new EmbeddingDisabledError(
'This brain was initialized with `--no-embedding` (deferred setup).\n' +
'Configure an embedding provider before running embed / import:\n' +
' gbrain init --force --embedding-model <provider>:<model> # re-init to size schema\n' +
'(`gbrain config set embedding_model` is refused — schema-sizing fields are set at init.)\n',
' gbrain config set embedding_model <provider>:<model>\n' +
' gbrain config set embedding_dimensions <N>\n' +
' gbrain init --force --embedding-model <provider>:<model> # re-init to size schema\n',
);
}
}
+5 -1
View File
@@ -3823,7 +3823,7 @@ const whoami: Operation = {
name: 'whoami',
description:
'Introspect the calling identity. Returns one of three transport shapes: ' +
'{transport: "oauth", client_id, client_name, scopes, expires_at}, ' +
'{transport: "oauth", client_id, client_name, scopes, expires_at, source_id, federated_read}, ' +
'{transport: "legacy", token_name, scopes, expires_at: null}, or ' +
'{transport: "local", scopes: []}, or {transport: "stdio", scopes: []} ' +
'for the auth-less stdio MCP pipe. Throws unknown_transport when the ' +
@@ -3865,6 +3865,10 @@ const whoami: Operation = {
client_name: ctx.auth.clientName ?? ctx.auth.clientId,
scopes: ctx.auth.scopes,
expires_at: ctx.auth.expiresAt ?? null,
// Read-only self-introspection of the token's source grants —
// widens nothing; absent grants serialize fail-closed (null / []).
source_id: ctx.auth.sourceId ?? null,
federated_read: ctx.auth.allowedSources ?? [],
};
}
return {
+65
View File
@@ -0,0 +1,65 @@
/**
* Brainstorm model configurability (takeover of PR #1855 by @starm2010).
*
* - The cost preview + hard cost ceiling price the model that will actually
* run: --model override configured chat_model gateway fallback. Before
* this, the preview always priced anthropic:claude-sonnet-4-6 even when
* the configured chat_model was something else.
* - The judge phase honors the `models.brainstorm.judge` config key when no
* --judge-model flag is passed.
*/
import { describe, test, expect } from 'bun:test';
import {
resolveBrainstormChatModel,
resolveBrainstormJudgeModel,
} from '../../src/core/brainstorm/orchestrator.ts';
import type { BrainEngine } from '../../src/core/engine.ts';
function mockEngine(configValues: Record<string, string>): { engine: BrainEngine; reads: string[] } {
const reads: string[] = [];
const engine = {
async getConfig(key: string): Promise<string | null> {
reads.push(key);
return configValues[key] ?? null;
},
} as unknown as BrainEngine;
return { engine, reads };
}
describe('resolveBrainstormChatModel', () => {
test('--model override wins over config', () => {
expect(resolveBrainstormChatModel({ chat_model: 'openai:gpt-5' }, 'anthropic:claude-opus-4-6'))
.toBe('anthropic:claude-opus-4-6');
});
test('configured chat_model wins over the hardcoded fallback', () => {
expect(resolveBrainstormChatModel({ chat_model: 'openai:gpt-5' }))
.toBe('openai:gpt-5');
});
test('falls back to the gateway default model when nothing is configured', () => {
expect(resolveBrainstormChatModel({})).toBe('anthropic:claude-sonnet-4-6');
});
});
describe('resolveBrainstormJudgeModel', () => {
test('--judge-model flag wins without touching config', async () => {
const { engine, reads } = mockEngine({ 'models.brainstorm.judge': 'openai:gpt-5' });
const out = await resolveBrainstormJudgeModel(engine, 'anthropic:claude-opus-4-6');
expect(out).toBe('anthropic:claude-opus-4-6');
expect(reads).toHaveLength(0);
});
test('models.brainstorm.judge config key is honored when no flag is passed', async () => {
const { engine, reads } = mockEngine({ 'models.brainstorm.judge': 'openai:gpt-5' });
const out = await resolveBrainstormJudgeModel(engine);
expect(out).toBe('openai:gpt-5');
expect(reads).toEqual(['models.brainstorm.judge']);
});
test('returns undefined (defer to modelOverride / gateway default) when unset', async () => {
const { engine } = mockEngine({});
expect(await resolveBrainstormJudgeModel(engine)).toBeUndefined();
});
});
-111
View File
@@ -1,111 +0,0 @@
/**
* #2301 re-init with an explicit --embedding-model must recover a brain
* that was initialized with --no-embedding (deferred setup).
*
* Pre-fix: resolveAIOptions honored the persisted `embedding_disabled: true`
* sentinel BEFORE the explicit flag and never cleared noEmbedding, and the
* persistence merge carried the sentinel forward via ...existingFile. Result:
* every re-init (including the recovery command the deferred-setup error
* itself recommends) silently re-deferred embedding, forever.
*
* Hermetic: in-process runInit, GBRAIN_HOME pinned to a tmpdir (same pattern
* as test/e2e/fresh-install-pglite.test.ts).
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, rmSync, readFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { configureGateway, resetGateway } from '../../src/core/ai/gateway.ts';
describe('E2E: re-init with --embedding-model after --no-embedding init (#2301)', () => {
let tmpHome: string;
let origHome: string | undefined;
let origZeKey: string | undefined;
let origOpenaiKey: string | undefined;
let origVoyageKey: string | undefined;
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-e2e-reinit-'));
origHome = process.env.GBRAIN_HOME;
origZeKey = process.env.ZEROENTROPY_API_KEY;
origOpenaiKey = process.env.OPENAI_API_KEY;
origVoyageKey = process.env.VOYAGE_API_KEY;
delete process.env.OPENAI_API_KEY;
delete process.env.VOYAGE_API_KEY;
process.env.GBRAIN_HOME = tmpHome;
process.env.ZEROENTROPY_API_KEY = 'sk-test-ze';
resetGateway();
});
afterEach(() => {
rmSync(tmpHome, { recursive: true, force: true });
if (origHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = origHome;
if (origZeKey === undefined) delete process.env.ZEROENTROPY_API_KEY;
else process.env.ZEROENTROPY_API_KEY = origZeKey;
if (origOpenaiKey !== undefined) process.env.OPENAI_API_KEY = origOpenaiKey;
if (origVoyageKey !== undefined) process.env.VOYAGE_API_KEY = origVoyageKey;
// Restore legacy-preload gateway state (mirrors fresh-install-pglite.test.ts).
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { ...process.env },
});
});
async function runInitCapturing(args: string[]): Promise<string> {
const { runInit } = await import('../../src/commands/init.ts');
const origLog = console.log;
const origWarn = console.warn;
const stdoutBuf: string[] = [];
console.log = (...a: unknown[]) => {
stdoutBuf.push(a.map(x => (typeof x === 'string' ? x : JSON.stringify(x))).join(' '));
};
console.warn = () => {};
try {
await runInit(args);
} finally {
console.log = origLog;
console.warn = origWarn;
}
return stdoutBuf.join('\n');
}
const cfgPath = () => join(tmpHome, '.gbrain', 'config.json');
const readCfg = () => JSON.parse(readFileSync(cfgPath(), 'utf-8'));
test('explicit --embedding-model clears the persisted embedding_disabled sentinel', async () => {
// Step 1: deferred-setup init writes the sentinel.
const out1 = await runInitCapturing(['--pglite', '--non-interactive', '--no-embedding']);
expect(out1).toContain('deferred setup');
const cfg1 = readCfg();
expect(cfg1.embedding_disabled).toBe(true);
expect(cfg1.embedding_model).toBeUndefined();
// Step 2: re-init with an explicit embedding model — the recovery path.
// Pre-fix this printed the deferred-setup line again and re-persisted
// embedding_disabled: true.
const out2 = await runInitCapturing([
'--pglite', '--non-interactive', '--skip-embed-check',
'--embedding-model', 'zeroentropyai:zembed-1',
'--embedding-dimensions', '1280',
]);
expect(out2).not.toContain('deferred setup');
expect(out2).toContain('zeroentropyai:zembed-1');
const cfg2 = readCfg();
expect(cfg2.embedding_model).toBe('zeroentropyai:zembed-1');
expect(cfg2.embedding_dimensions).toBe(1280);
expect(cfg2.embedding_disabled).toBeUndefined();
}, 60000);
test('re-init WITHOUT flags still honors the deferred-setup sentinel (no regression)', async () => {
await runInitCapturing(['--pglite', '--non-interactive', '--no-embedding']);
const out = await runInitCapturing(['--pglite', '--non-interactive']);
expect(out).toContain('deferred setup');
const cfg = readCfg();
expect(cfg.embedding_disabled).toBe(true);
expect(cfg.embedding_model).toBeUndefined();
}, 60000);
});
+77
View File
@@ -15,6 +15,7 @@
*/
import { describe, test, expect } from 'bun:test';
import { withEnv, emptyHome } from './helpers/with-env.ts';
import {
runPhaseProposeTakes,
parseExtractorOutput,
@@ -52,6 +53,14 @@ function buildMockEngine(opts: {
},
async executeRaw<T>(sql: string, params?: unknown[]): Promise<T[]> {
captured.push({ sql, params: params ?? [] });
// Narrow candidate-page projection (replaces listPages in the phase).
if (sql.includes('SELECT slug, source_id, compiled_truth')) {
return opts.pages.map((p) => ({
slug: p.slug,
source_id: p.source_id,
compiled_truth: p.compiled_truth,
})) as T[];
}
// SELECT idempotency check
if (sql.includes('SELECT id FROM take_proposals')) {
const [sourceId, slug, ch, pv] = params ?? [];
@@ -476,4 +485,72 @@ New prose appended here.`;
resetGateway();
}
});
test('default extractor skips cleanly when the Anthropic chat model has no key', async () => {
// Empty GBRAIN_HOME so hasAnthropicKey's config-file fallback can't find
// the operator's real key.
await withEnv({ GBRAIN_HOME: emptyHome(), ANTHROPIC_API_KEY: undefined }, async () => {
configureGateway({ chat_model: 'anthropic:claude-sonnet-4-6', env: {} });
try {
const { engine, captured } = buildMockEngine({
pages: [buildPage({ slug: 'wiki/a', body: 'claim-ish prose' })],
});
const result = await runPhaseProposeTakes(buildCtx(engine));
expect(result.status).toBe('skipped');
expect((result.details as Record<string, unknown>).reason).toBe('no_provider');
// Skips BEFORE touching the engine — no page scan, no cache probes.
expect(captured).toHaveLength(0);
} finally {
resetGateway();
}
});
});
test('an injected extractor is never gated on provider availability', async () => {
await withEnv({ GBRAIN_HOME: emptyHome(), ANTHROPIC_API_KEY: undefined }, async () => {
configureGateway({ chat_model: 'anthropic:claude-sonnet-4-6', env: {} });
try {
const { engine } = buildMockEngine({
pages: [buildPage({ slug: 'wiki/b', body: 'still processed' })],
});
const extractor: ProposeTakesExtractor = async () => [];
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
expect(result.status).toBe('ok');
expect((result.details as Record<string, unknown>).pages_scanned).toBe(1);
} finally {
resetGateway();
}
});
});
test('loads proposal candidates with a narrow page projection', async () => {
const pages = [buildPage({ slug: 'wiki/narrow', body: 'A narrow projection avoids unrelated page columns.' })];
const { engine, captured } = buildMockEngine({ pages });
const extractor: ProposeTakesExtractor = async () => [];
await runPhaseProposeTakes(buildCtx(engine), { extractor });
const pageSelect = captured.find(c => c.sql.includes('FROM pages'));
expect(pageSelect).toBeDefined();
expect(pageSelect!.sql).toContain('SELECT slug, source_id, compiled_truth');
expect(pageSelect!.sql).not.toContain('*');
// Scalar sourceId scope from ctx binds as a plain equality param.
expect(pageSelect!.params[0]).toBe('default');
});
test('narrow projection: federated sourceIds beat scalar sourceId', async () => {
const { engine, captured } = buildMockEngine({ pages: [] });
const extractor: ProposeTakesExtractor = async () => [];
const ctx = {
...buildCtx(engine),
auth: { allowedSources: ['team-a', 'team-b'] },
} as OperationContext;
await runPhaseProposeTakes(ctx, { extractor });
const pageSelect = captured.find(c => c.sql.includes('FROM pages'));
expect(pageSelect).toBeDefined();
expect(pageSelect!.sql).toContain('source_id = ANY(');
expect(pageSelect!.params[0]).toEqual(['team-a', 'team-b']);
});
});
+63 -6
View File
@@ -55,23 +55,75 @@ describe('whoami op contract', () => {
expect(result.scopes).toEqual([]);
});
test('oauth transport returns full client identity', async () => {
test('oauth transport returns client identity and exact source grants', async () => {
const auth: AuthInfo = {
token: 'gbrain_at_xxx',
clientId: 'gbrain_cl_abc',
clientName: 'gstack-test',
scopes: ['read', 'sources_admin'],
expiresAt: 1234567890,
sourceId: 'hot-memory',
allowedSources: ['hot-memory', 'canonical-brain'],
};
const result = (await whoami.handler(
ctxWith({ remote: true, sourceId: 'transport-fallback', auth }),
{},
)) as any;
expect(result).toEqual({
transport: 'oauth',
client_id: 'gbrain_cl_abc',
client_name: 'gstack-test',
scopes: ['read', 'sources_admin'],
expires_at: 1234567890,
source_id: 'hot-memory',
federated_read: ['hot-memory', 'canonical-brain'],
});
});
test('oauth transport uses fail-closed empty values when source grants are absent', async () => {
const auth: AuthInfo = {
token: 'gbrain_at_pre_migration',
clientId: 'gbrain_cl_pre_migration',
scopes: ['read'],
};
const result = (await whoami.handler(
ctxWith({ remote: true, sourceId: 'transport-fallback', auth }),
{},
)) as any;
expect(result.source_id).toBeNull();
expect(result.federated_read).toEqual([]);
});
test('oauth transport preserves an explicit empty federated grant', async () => {
const auth: AuthInfo = {
token: 'gbrain_at_empty',
clientId: 'gbrain_cl_empty',
scopes: ['read', 'write'],
sourceId: 'hot-memory',
allowedSources: [],
};
const result = (await whoami.handler(
ctxWith({ remote: true, auth }),
{},
)) as any;
expect(result.transport).toBe('oauth');
expect(result.client_id).toBe('gbrain_cl_abc');
expect(result.client_name).toBe('gstack-test');
expect(result.scopes).toEqual(['read', 'sources_admin']);
expect(result.expires_at).toBe(1234567890);
expect(result.source_id).toBe('hot-memory');
expect(result.federated_read).toEqual([]);
});
test('oauth transport does not widen federated_read with the write source', async () => {
const auth: AuthInfo = {
token: 'gbrain_at_narrow',
clientId: 'gbrain_cl_narrow',
scopes: ['read', 'write'],
sourceId: 'hot-memory',
allowedSources: ['canonical-brain'],
};
const result = (await whoami.handler(
ctxWith({ remote: true, auth }),
{},
)) as any;
expect(result.source_id).toBe('hot-memory');
expect(result.federated_read).toEqual(['canonical-brain']);
});
test('legacy transport (token name as clientId, no gbrain_cl_ prefix)', async () => {
@@ -150,6 +202,11 @@ describe('whoami op contract', () => {
});
describe('whoami op metadata', () => {
test('description documents OAuth source grant fields', () => {
expect(whoami.description).toContain('source_id');
expect(whoami.description).toContain('federated_read');
});
test('scope is read (any authenticated caller can introspect itself)', () => {
expect(whoami.scope).toBe('read');
});