Files
gbrain/src/core/source-resolver.ts
T
03cd52631b fix(thin-client): map --source scope onto source_id for remote-routed ops (#3086)
* fix(thin-client): map --source/GBRAIN_SOURCE/.gbrain-source onto source_id for routed ops (#2098)

The thin-client route short-circuits before makeContext, so the 6-tier
source resolution never ran and `gbrain query --source X` against a
remote brain sent the unknown `source` key verbatim — the server op
ignored it and searched unscoped.

applyThinClientSourceScope now runs the engine-free tiers (flag → env →
dotfile; DB-backed tiers need an engine, and the server's grant scoping
covers the rest) and sets the op's source_id wire param. Ops declaring
their own `source` param are untouched; an explicit --source on an op
with no source_id wire param errors loudly instead of silently dropping;
explicit --source-id/--all-sources on the wire win over ambient tiers.

Fixes #2098

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

* test(thin-client): use withEnv() instead of direct process.env mutation (test-isolation R1)

CI verify failed on check:test-isolation — thin-client-source-scope.test.ts
mutated process.env.GBRAIN_SOURCE via beforeEach/afterEach. Wrapped each
test body in withEnv() from test/helpers/with-env.ts instead.

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

* fix(thin-client): keep ambient scope out of get_skill's non-scope source_id param

get_skill's source_id is a mode switch (host catalog vs brain-resident-pack
lookup), not a read-scope filter. Ambient GBRAIN_SOURCE / .gbrain-source
injection would silently reroute 'gbrain skill <name>' on thin clients to
getResidentSkillDetail. Exclude it via NON_SCOPE_SOURCE_ID_OPS; explicit
--source-id still passes through, explicit --source errors with a hint.

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

---------

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-23 16:45:36 -07:00

427 lines
17 KiB
TypeScript

/**
* Source resolution for CLI commands (v0.18.0).
*
* Resolution priority (highest first):
* 1. Explicit --source <id> flag (caller passes this as `explicit`)
* 2. GBRAIN_SOURCE env var
* 3. .gbrain-source dotfile in CWD or any ancestor directory
* 4. Registered source whose local_path contains CWD
* 5. Brain-level default via `gbrain sources default <id>`
* 6. Literal 'default' (backward compat for pre-v0.17 brains)
*
* This helper is shared by the sources CLI, future sync/extract/query
* commands (Steps 4/5), and the operation layer (Step 2+).
*/
import { readFileSync, lstatSync, type Stats } from 'fs';
import { join, dirname, resolve } from 'path';
import type { BrainEngine } from './engine.ts';
import { SOURCE_ID_RE, isValidSourceId } from './source-id.ts';
import { isTrustedDotfile, realpathOrResolve } from './path-confine.ts';
const DOTFILE = '.gbrain-source';
// Canonical SOURCE_ID_RE imported from `source-id.ts` (single source of truth).
// Re-exported below as `__testing.SOURCE_ID_RE` for legacy test imports.
// Two validator shapes per codex r2 P1-F:
// - `isValidSourceId(s)`: boolean — used by tiers that silently fall through
// on invalid input (dotfile tier 3, brain_default tier 5)
// - explicit throw — used by tiers that must reject loudly with a tailored
// message (explicit `--source` flag tier 1, GBRAIN_SOURCE env tier 2).
// Tier-specific messages are clearer than the generic assertValidSourceId
// error, so the throws stay inline.
function readDotfileWalk(startDir: string): string | null {
let dir = resolve(startDir);
// Guard against infinite loops on malformed paths.
for (let i = 0; i < 50; i++) {
const candidate = join(dir, DOTFILE);
// lstatSync (NOT statSync) so a planted symlink is seen here, not silently
// followed-then-trusted. Any stat error (ENOENT / permission) → skip this
// candidate and keep walking (fail-closed). On a multi-user host an
// attacker who can write a shared ancestor dir could otherwise plant a
// forged `.gbrain-source`; `isTrustedDotfile` refuses symlinks,
// foreign-owned, and world-writable files (#418).
let st: Stats | null = null;
try { st = lstatSync(candidate); } catch { st = null; }
if (st && isTrustedDotfile(st)) {
try {
const content = readFileSync(candidate, 'utf8').trim().split('\n')[0].trim();
// Silent-fallback tier per codex P1-F: invalid dotfile content
// (legacy ids with underscores, hand-edits with whitespace, etc.)
// falls through to the next tier instead of throwing. The CLI's
// explicit/env tiers throw; dotfiles are operator-edited and the
// forgiving behavior preserves the resolver's existing semantics.
if (isValidSourceId(content)) return content;
} catch {
// Unreadable dotfile — skip and keep walking.
}
}
const parent = dirname(dir);
if (parent === dir) break; // reached filesystem root
dir = parent;
}
return null;
}
/**
* Resolve the source id for a CLI command.
*
* @param engine Connected brain engine (for sources table lookups).
* @param explicit The --source <id> flag value, if the caller parsed one.
* @param cwd The working directory to walk for .gbrain-source. Defaults
* to process.cwd(). Exposed for testability.
* @returns The resolved source id. Falls back to 'default' if no other
* signal is present. Never returns null — every command must
* target exactly one default source.
* @throws If the resolved id doesn't correspond to a registered source
* (prevents silently writing to a nonexistent source and bloating
* pages with a dead FK).
*/
export async function resolveSourceId(
engine: BrainEngine,
explicit: string | null | undefined,
cwd: string = process.cwd(),
): Promise<string> {
// 1. Explicit flag wins.
if (explicit) {
if (!SOURCE_ID_RE.test(explicit)) {
throw new Error(`Invalid --source value "${explicit}". Must match [a-z0-9-]{1,32}.`);
}
await assertSourceExists(engine, explicit);
return explicit;
}
// 2. Env var.
const env = process.env.GBRAIN_SOURCE;
if (env && env.length > 0) {
if (!SOURCE_ID_RE.test(env)) {
throw new Error(`Invalid GBRAIN_SOURCE value "${env}". Must match [a-z0-9-]{1,32}.`);
}
await assertSourceExists(engine, env);
return env;
}
// 3. .gbrain-source dotfile walk-up.
const dotfile = readDotfileWalk(cwd);
if (dotfile) {
await assertSourceExists(engine, dotfile);
return dotfile;
}
// 4. Registered source whose local_path contains CWD.
// Uses longest-prefix match so nested-path configurations (e.g.
// gstack at ~/gstack + plans at ~/gstack/plans) pick the deepest.
const registered = await engine.executeRaw<{ id: string; local_path: string }>(
`SELECT id, local_path FROM sources WHERE local_path IS NOT NULL`,
);
// realpath BOTH sides (not bare resolve) so a symlinked CWD can't forge a
// prefix match against a registered local_path it doesn't really live under
// (codex #9). realpathOrResolve falls back to lexical resolve() for a stale
// registration whose path no longer exists. Resolving both sides keeps a
// legitimately symlinked vault matching — only one-sided symlinks break.
const cwdResolved = realpathOrResolve(cwd);
let best: { id: string; pathLen: number } | null = null;
for (const r of registered) {
const p = realpathOrResolve(r.local_path);
if (cwdResolved === p || cwdResolved.startsWith(p + '/')) {
if (!best || p.length > best.pathLen) {
best = { id: r.id, pathLen: p.length };
}
}
}
if (best) return best.id;
// 5. Brain-level default.
// Silent-fallback tier per codex P1-F: an invalid `sources.default` config
// value (operator hand-edit gone wrong, legacy underscore id) falls through
// to tier 6 rather than throwing. Resolver stays robust to bad config.
const globalDefault = await engine.getConfig('sources.default');
if (globalDefault && isValidSourceId(globalDefault)) {
await assertSourceExists(engine, globalDefault);
return globalDefault;
}
// 5.5. Single-non-default-source convenience (v0.41.13, #1434).
// When NO brain_default is set AND exactly one registered source has
// local_path set AND it isn't 'default', route there. This closes
// the "532 silent edit failures" bug class where users with a single
// Vault-mounted source ran `gbrain sync` without --source and routed
// to source_id='default' (which held 0 pages). Conservative: fires
// only when there's literally one option — multi-source brains still
// require explicit --source or sources.default.
//
// Placed AFTER brain_default per codex review: a user who explicitly
// set sources.default has stated intent, that wins over auto-routing.
const soleNonDefault = await pickSoleNonDefaultSource(engine);
if (soleNonDefault) return soleNonDefault;
// 6. Fallback: the seeded 'default' source. Always exists post-migration
// v16 so this is a safe terminal.
return 'default';
}
/**
* Engine-free tiers (1-3) of the resolution chain: explicit flag →
* GBRAIN_SOURCE env → .gbrain-source dotfile walk. Used by the thin-client
* CLI path (#2098), which has no local engine to run tiers 4-6 or
* assertSourceExists against — the remote server enforces existence + grant.
* Returns null when no engine-free tier fires.
*/
export function resolveSourceIdEngineFree(
explicit: string | null | undefined,
cwd: string = process.cwd(),
): string | null {
if (explicit) {
if (!SOURCE_ID_RE.test(explicit)) {
throw new Error(`Invalid --source value "${explicit}". Must match [a-z0-9-]{1,32}.`);
}
return explicit;
}
const env = process.env.GBRAIN_SOURCE;
if (env && env.length > 0) {
if (!SOURCE_ID_RE.test(env)) {
throw new Error(`Invalid GBRAIN_SOURCE value "${env}". Must match [a-z0-9-]{1,32}.`);
}
return env;
}
return readDotfileWalk(cwd);
}
/**
* Returns the id of the SINGLE registered non-default source with a
* local_path, when exactly one such row exists. Returns null when:
* - zero non-default sources are registered (fresh install)
* - 2+ non-default sources are registered (ambiguous — user must pick)
* - the only non-default source has a NULL local_path (no on-disk shape)
* - the only registered source IS 'default'
*
* Excludes archived sources (`archived = false`) so a soft-deleted source
* doesn't auto-resolve. Shared by `resolveSourceId` and `resolveSourceWithTier`
* so the heuristic can't drift between the two entry points.
*/
async function pickSoleNonDefaultSource(engine: BrainEngine): Promise<string | null> {
// archived column was added in v34 (v0.26.5). Older brains may not have
// it — fall back to the un-archived query in that case via try/catch.
let rows: Array<{ id: string }>;
try {
rows = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE local_path IS NOT NULL AND id != 'default' AND archived = false`,
);
} catch {
rows = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE local_path IS NOT NULL AND id != 'default'`,
);
}
if (rows.length === 1) return rows[0].id;
return null;
}
/**
* Format the one-line stderr nudge that fires when source resolution falls
* through to the `sole_non_default` tier. Returns null when suppressed via
* `GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1` (CI / scripted-pipeline ergonomics).
*
* Single source of truth so the wording stays consistent across every CLI
* dispatch site that fires the nudge (sync, import, extract, etc.). Callers
* print to stderr; this helper just builds the line.
*/
export function formatSoleNonDefaultNudge(sourceId: string): string | null {
if (process.env.GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE === '1') return null;
return `[gbrain] routing to source '${sourceId}' (sole non-default source registered; pass --source to override).`;
}
async function assertSourceExists(engine: BrainEngine, id: string): Promise<void> {
const rows = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE id = $1`,
[id],
);
if (rows.length === 0) {
throw new Error(
`Source "${id}" not found. Available sources: ` +
`run \`gbrain sources list\` to see registered sources, ` +
`or \`gbrain sources add ${id}\` to create it.`,
);
}
}
/**
* Get the local_path of the resolved source (per the resolveSourceId chain).
*
* Returns the on-disk brain repo path for the source the user is currently
* operating against. Used by `gbrain storage status` and `gbrain export
* --restore-only` to find the brain repo without raw SQL or bare try/catch.
*
* Resolution order:
* 1. `sources.local_path` for the resolved source id (multi-source v0.18+ path)
* 2. Legacy global `sync.repo_path` config key (pre-v0.18 default-source brains)
* 3. null
*
* @returns local_path string, or null if no path is configured anywhere.
* @throws If DB error occurs (does NOT silently swallow). Callers handle
* the null case to provide their own fallback (typically a hard error
* telling the user to pass --repo).
*/
export async function getDefaultSourcePath(
engine: BrainEngine,
cwd: string = process.cwd(),
): Promise<string | null> {
const sourceId = await resolveSourceId(engine, null, cwd);
const rows = await engine.executeRaw<{ local_path: string | null }>(
`SELECT local_path FROM sources WHERE id = $1`,
[sourceId],
);
if (rows[0]?.local_path) return rows[0].local_path;
// Legacy fallback: pre-v0.18 brains stored the repo path in the global
// config table under sync.repo_path. The sources table exists but its
// local_path is NULL for the seeded 'default' row. Fall back so storage
// tiering works without forcing a `gbrain sources add . --path .` migration.
const legacyPath = await engine.getConfig('sync.repo_path');
return legacyPath ?? null;
}
/**
* v0.37.7.0 — tier labels for `resolveSourceWithTier()`. Exported so
* `gbrain sources current --json` and downstream consumers share a
* canonical vocabulary instead of redefining strings inline.
*
* Order matches the 1-6 priority of `resolveSourceId()`.
*/
export const SOURCE_TIER_NAMES = [
'flag',
'env',
'dotfile',
'local_path',
'brain_default',
'sole_non_default',
'seed_default',
] as const;
export type SourceTier = typeof SOURCE_TIER_NAMES[number];
/**
* Same resolution chain as `resolveSourceId()`, but also returns
* WHICH tier won. Additive — does not duplicate the logic; runs the
* same six steps in the same order. Used by `gbrain sources current`
* so users can verify the resolved source AND the reason it resolved
* before destructive ops.
*
* @returns `{ source_id, tier, detail? }` where `detail` is an
* optional human-readable extra (e.g. the env-var name or
* the matched dotfile / local_path).
*/
export async function resolveSourceWithTier(
engine: BrainEngine,
explicit: string | null | undefined,
cwd: string = process.cwd(),
): Promise<{ source_id: string; tier: SourceTier; detail?: string }> {
// 1. Explicit flag wins.
if (explicit) {
if (!SOURCE_ID_RE.test(explicit)) {
throw new Error(`Invalid --source value "${explicit}". Must match [a-z0-9-]{1,32}.`);
}
await assertSourceExists(engine, explicit);
return { source_id: explicit, tier: 'flag', detail: `--source ${explicit}` };
}
// 2. Env var.
const env = process.env.GBRAIN_SOURCE;
if (env && env.length > 0) {
if (!SOURCE_ID_RE.test(env)) {
throw new Error(`Invalid GBRAIN_SOURCE value "${env}". Must match [a-z0-9-]{1,32}.`);
}
await assertSourceExists(engine, env);
return { source_id: env, tier: 'env', detail: `GBRAIN_SOURCE=${env}` };
}
// 3. .gbrain-source dotfile walk-up.
const dotfile = readDotfileWalk(cwd);
if (dotfile) {
await assertSourceExists(engine, dotfile);
return { source_id: dotfile, tier: 'dotfile', detail: `.gbrain-source` };
}
// 4. Registered source whose local_path contains CWD.
const registered = await engine.executeRaw<{ id: string; local_path: string }>(
`SELECT id, local_path FROM sources WHERE local_path IS NOT NULL`,
);
// realpath both sides — see the matching block in resolveSourceId (codex #9).
const cwdResolved = realpathOrResolve(cwd);
let best: { id: string; path: string; pathLen: number } | null = null;
for (const r of registered) {
const p = realpathOrResolve(r.local_path);
if (cwdResolved === p || cwdResolved.startsWith(p + '/')) {
if (!best || p.length > best.pathLen) {
best = { id: r.id, path: p, pathLen: p.length };
}
}
}
if (best) return { source_id: best.id, tier: 'local_path', detail: best.path };
// 5. Brain-level default. Silent-fallback (P1-F) like tier 5 in resolveSourceId.
const globalDefault = await engine.getConfig('sources.default');
if (globalDefault && isValidSourceId(globalDefault)) {
await assertSourceExists(engine, globalDefault);
return { source_id: globalDefault, tier: 'brain_default', detail: 'sources.default config' };
}
// 5.5. Single-non-default-source convenience (v0.41.13, #1434).
// See resolveSourceId for the design rationale. Same helper, same
// precedence (AFTER brain_default).
const soleNonDefault = await pickSoleNonDefaultSource(engine);
if (soleNonDefault) {
return {
source_id: soleNonDefault,
tier: 'sole_non_default',
detail: `only non-default registered source with local_path`,
};
}
// 6. Fallback: seeded 'default' source.
return { source_id: 'default', tier: 'seed_default' };
}
/**
* #2561 — compute the federated read scope for an UNQUALIFIED local CLI call.
*
* `sources add --federated` promises that a `config.federated = true` source
* "participates in unqualified `gbrain search` results"
* (docs/guides/multi-source-brains.md). This helper turns that promise into a
* scope: given the resolved source and WHICH tier resolved it, return
* `[resolvedSource, ...other federated source ids]` — or `undefined` when the
* expansion must not apply:
*
* - explicit tiers (`flag` / `env` / `dotfile`): the user named a source;
* scalar scope stands (that IS the qualified case);
* - no other federated source exists: keep the scalar fast path unchanged.
*
* Archived sources are excluded (same rationale as pickSoleNonDefaultSource);
* the archived column is v34+, so fall back to the un-archived query on older
* brains. Callers put the result on `OperationContext.localFederatedSourceIds`
* — consumed only by `federatedSearchScope` and only when `remote === false`.
*/
export async function localFederatedSourceIds(
engine: BrainEngine,
sourceId: string,
tier: SourceTier,
): Promise<string[] | undefined> {
if (tier === 'flag' || tier === 'env' || tier === 'dotfile') return undefined;
let rows: Array<{ id: string }>;
try {
rows = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE config->>'federated' = 'true' AND archived = false ORDER BY id`,
);
} catch {
rows = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE config->>'federated' = 'true' ORDER BY id`,
);
}
const ids = [sourceId, ...rows.map((r) => r.id).filter((id) => id !== sourceId)];
return ids.length > 1 ? ids : undefined;
}
/** Exposed for tests. */
export const __testing = {
readDotfileWalk,
SOURCE_ID_RE,
};