Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 3fa01a4538 fix(cycle): ambiguous local_path resolves no source — don't stamp an arbitrary pick
resolveSourceForDir used LIMIT 1, so when two sources share a local_path
the dir-keyed freshness stamp (new in this PR) fired for a
nondeterministic one — exactly the 'freshness stamp that lies' the
cycleSourceId comment warns against, and the CI failure in
test/dream.test.ts ('gbrain dream (no --source) leaves all sources
untouched'). Require exactly one match; ambiguous or no match falls back
to the pre-v0.18 behavior (undefined), same as before this PR for the
no-match case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:06:41 -07:00
ded4aeaeae fix(cycle): stamp last_full_cycle_at for the resolved source; stale freshness forces autopilot fanout (#1993, #2060)
Two fixes to the per-source cycle freshness loop:

1. runCycle's freshness stamp now keys off cycleSourceId (opts.sourceId ??
   the source resolved from brainDir) — the same id the cycle locked and
   scoped its phases to — instead of raw opts.sourceId. The autopilot's
   inline cycle passes brainDir with no explicit sourceId, so the stamp
   never fired and cycle_freshness stayed stale forever. Keeps master's
   !aborted guard and the last_source_cycle_at write. (takeover of #1993)

2. autopilot's dispatch decision now consults per-source cycle staleness:
   countStaleSources (new pure helper in autopilot-fanout.ts) over
   listAllSources({ localPathOnly: true }). A stale source forces the
   fanout path and blocks the healthy-sleep gate, so a brain sitting at
   score 70-94 with a small targeted plan can no longer starve per-source
   cycle dispatch indefinitely. Fail-open to 0 on read errors;
   dispatchPerSource's existing throttles (skipped_fresh / fanoutMax /
   failure cooldown) bound the work. (#2060)

Tests: cycle-last-full-cycle-at gains the brainDir-resolves-source and
brainDir-matches-nothing cases (first one fails without fix 1);
autopilot-fanout unit tests cover countStaleSources; the fanout wiring
guard pins the staleCycleSources terms in shouldFullCycle/shouldSleep.

Co-authored-by: 100menotu001 <100menotu001@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:24:01 -07:00
12 changed files with 133 additions and 166 deletions
+12
View File
@@ -186,6 +186,18 @@ export function isSourceStale(src: SourceRow, now = Date.now(), floorMin = FULL_
return ageMin >= floorMin;
}
/**
* #2060: count sources past the per-source cycle freshness floor. Consumed
* by autopilot's dispatch decision — a stale source forces the fanout path
* even when the doctor plan is small (score 7094, plan ≤ 3, est < 300s),
* so targeted mode can't leave cycle_freshness stale indefinitely.
* dispatchPerSource's own throttles (skipped_fresh / fanoutMax / failure
* cooldown) bound the resulting work.
*/
export function countStaleSources(sources: SourceRow[], now = Date.now(), floorMin = FULL_CYCLE_FLOOR_MIN): number {
return sources.filter((s) => isSourceStale(s, now, floorMin)).length;
}
/**
* Most recent SUCCESSFUL cycle for a source. Prefers `last_source_cycle_at`
* (per-source phases, written by the split cycle) and falls back to the legacy
+16 -2
View File
@@ -901,13 +901,27 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
const FULL_CYCLE_FLOOR_MIN = 60;
const minutesSinceLastFull = (Date.now() - lastFullCycleAt) / 60000;
// #2060: stale per-source cycle freshness is a dispatch input. Without
// it, a brain sitting at score 7094 with a small targeted plan (≤3
// steps, <300s) stays in targeted mode indefinitely and no per-source
// cycle is ever dispatched — cycle_freshness never advances. A stale
// source forces the fanout path; dispatchPerSource's throttles
// (skipped_fresh / fanoutMax / failure cooldown) bound the work.
// Fail-open to 0: a read failure must not block dispatch.
let staleCycleSources = 0;
try {
const { countStaleSources } = await import('./autopilot-fanout.ts');
staleCycleSources = countStaleSources(await engine.listAllSources({ localPathOnly: true }));
} catch { /* fail-open: freshness is a dispatch hint, not a gate */ }
const shouldFullCycle =
(score >= 95 && plan.length === 0 && minutesSinceLastFull >= FULL_CYCLE_FLOOR_MIN) ||
plan.length > 3 ||
estTotal >= 300 ||
score < 70;
score < 70 ||
staleCycleSources > 0;
const shouldSleep = score >= 95 && plan.length === 0 && minutesSinceLastFull < FULL_CYCLE_FLOOR_MIN;
const shouldSleep = score >= 95 && plan.length === 0 && minutesSinceLastFull < FULL_CYCLE_FLOOR_MIN && staleCycleSources === 0;
if (shouldSleep) {
if (jsonMode) {
+12 -42
View File
@@ -161,7 +161,7 @@ interface ResolveAIOptionsArgs {
nonInteractive: boolean; // --non-interactive (forces D3 fail-loud, no picker)
}
export interface ResolvedAIOptions {
interface ResolvedAIOptions {
embedding_model?: string;
embedding_dimensions?: number;
expansion_model?: string;
@@ -170,41 +170,6 @@ export interface ResolvedAIOptions {
noEmbedding?: boolean;
}
/**
* Seed init's AI options from persisted config, falling back to the raw env
* vars when loadConfig() returned null (#1058). On a cold install (no
* config.json AND no DATABASE_URL) loadConfig short-circuits BEFORE its env
* merge, so GBRAIN_EMBEDDING_MODEL / GBRAIN_EMBEDDING_DIMENSIONS /
* GBRAIN_EXPANSION_MODEL / GBRAIN_CHAT_MODEL were silently ignored by init
* and Tier-3 detection auto-picked by API key instead. Exported for unit
* tests (env injectable).
*/
export function seedAIOptionsFromConfig(
cfg: GBrainConfig | null,
env: NodeJS.ProcessEnv = process.env,
): ResolvedAIOptions {
const envDims = env.GBRAIN_EMBEDDING_DIMENSIONS
? parseInt(env.GBRAIN_EMBEDDING_DIMENSIONS, 10)
: NaN;
const seed = cfg ?? {
embedding_disabled: undefined,
embedding_model: env.GBRAIN_EMBEDDING_MODEL,
embedding_dimensions: Number.isFinite(envDims) ? envDims : undefined,
expansion_model: env.GBRAIN_EXPANSION_MODEL,
chat_model: env.GBRAIN_CHAT_MODEL,
};
const out: ResolvedAIOptions = {};
if (seed.embedding_disabled) {
out.noEmbedding = true;
} else if (seed.embedding_model) {
out.embedding_model = seed.embedding_model;
if (seed.embedding_dimensions) out.embedding_dimensions = seed.embedding_dimensions;
}
if (seed.expansion_model) out.expansion_model = seed.expansion_model;
if (seed.chat_model) out.chat_model = seed.chat_model;
return out;
}
/**
* Resolve AI provider options for `gbrain init`.
*
@@ -238,13 +203,18 @@ async function resolveAIOptions(opts: ResolveAIOptionsArgs): Promise<ResolvedAIO
// user already opted into deferred mode.
try {
const { loadConfig } = await import('../core/config.ts');
// #1058: loadConfig() returns null on a cold install (no config.json AND
// no DATABASE_URL) — before it ever reaches its env merge. The seed helper
// falls back to the same GBRAIN_* env vars directly in that case.
Object.assign(out, seedAIOptionsFromConfig(loadConfig()));
const cfg = loadConfig();
if (cfg?.embedding_disabled) {
out.noEmbedding = true;
} else if (cfg?.embedding_model) {
out.embedding_model = cfg.embedding_model;
if (cfg.embedding_dimensions) out.embedding_dimensions = cfg.embedding_dimensions;
}
if (cfg?.expansion_model) out.expansion_model = cfg.expansion_model;
if (cfg?.chat_model) out.chat_model = cfg.chat_model;
} catch {
// loadConfig threw — treat as first-time install, fall through to env
// detection.
// loadConfig throws when no brain configured — first-time install, fall
// through to env detection.
}
// --- Tier 1+2: explicit flags ---------------------------------------------
+19 -10
View File
@@ -854,7 +854,10 @@ interface SyncPhaseResult extends PhaseResult {
/**
* Resolve the source id for a brain directory by looking up the sources
* table. Returns undefined when no registered source matches (falls back
* to pre-v0.18 global config.sync.* keys).
* to pre-v0.18 global config.sync.* keys) OR when MORE than one source
* claims the path — an ambiguous match must not scope phases or stamp
* last_full_cycle_at for an arbitrarily-picked source (the "freshness
* stamp that lies" this resolution exists to prevent).
*/
async function resolveSourceForDir(
engine: BrainEngine,
@@ -865,10 +868,10 @@ async function resolveSourceForDir(
if (brainDir === null) return undefined;
try {
const rows = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE local_path = $1 LIMIT 1`,
`SELECT id FROM sources WHERE local_path = $1 LIMIT 2`,
[brainDir],
);
return rows[0]?.id;
return rows.length === 1 ? rows[0]!.id : undefined;
} catch {
// sources table might not exist on very old brains — fall through.
return undefined;
@@ -2365,17 +2368,23 @@ export async function runCycle(
}
// v0.38 (codex r1 P0-5): persist per-source cycle completion timestamp
// when the cycle ran successfully against an explicit source. Read by
// autopilot's per-source freshness gate next tick. Skipped when:
// - opts.sourceId is unset (legacy callers — autopilot still here)
// - engine is null (no-DB path)
// when the cycle ran successfully against a resolvable source. Read by
// autopilot's per-source freshness gate next tick.
//
// #1993: keyed off `cycleSourceId` (opts.sourceId ?? the source resolved
// from brainDir) — the SAME id the cycle locked + scoped its phases to —
// NOT raw opts.sourceId. The autopilot's inline cycle sets brainDir but
// passes no explicit sourceId, so keying off opts.sourceId alone never
// advanced last_full_cycle_at and cycle_freshness stayed stale even while
// the autopilot cycled every interval. Skipped when:
// - no source resolves (engine null, or no checkout AND no opts.sourceId)
// - status is 'failed' or 'skipped' (don't mark a non-run as fresh)
// - dryRun (writes are out of scope)
//
// Best-effort: a write failure does NOT change the CycleReport status.
// The cost of writing the wrong timestamp post-failure is higher than
// the cost of missing a successful write (next cycle will redo work).
if (opts.sourceId && engine && !dryRun && !aborted && (status === 'ok' || status === 'clean' || status === 'partial')) {
if (cycleSourceId && engine && !dryRun && !aborted && (status === 'ok' || status === 'clean' || status === 'partial')) {
try {
const nowIso = new Date().toISOString();
// #2194 fix #3 (the cycle split): `last_source_cycle_at` is the NEW gate
@@ -2385,13 +2394,13 @@ export async function runCycle(
// phases (those gate on autopilot.last_global_at), so writing it on a
// source-only cycle does not re-introduce the freshness poisoning codex
// flagged in the rejected skip-based design.
await engine.updateSourceConfig(opts.sourceId, {
await engine.updateSourceConfig(cycleSourceId, {
last_source_cycle_at: nowIso,
last_full_cycle_at: nowIso,
});
} catch (e) {
// Best-effort; cycle already succeeded by the time we get here.
console.warn(`[cycle] failed to write last_source_cycle_at for source ${opts.sourceId}: ${e instanceof Error ? e.message : String(e)}`);
console.warn(`[cycle] failed to write last_source_cycle_at for source ${cycleSourceId}: ${e instanceof Error ? e.message : String(e)}`);
}
}
+3 -19
View File
@@ -332,15 +332,6 @@ export interface OperationContext {
* remote/untrusted (defense in depth in case the type is bypassed via cast).
*/
remote: boolean;
/**
* Transport marker for auth-less remote surfaces (#1061). The stdio MCP
* dispatch sets 'stdio' — it is deliberately `remote: true` (agent-facing,
* untrusted) but has no per-token auth (local pipe), so identity ops like
* whoami need a way to distinguish "known auth-less transport" from "a
* transport bug forgot to thread ctx.auth". Trust decisions MUST NOT key
* off this field — only `ctx.remote === false` grants trust.
*/
transport?: 'stdio';
/**
* Subagent runtime context (v0.16+). Set by the subagent tool dispatcher when
* dispatching an op as a tool call from an LLM loop. Used to enforce per-op
@@ -3722,10 +3713,9 @@ const whoami: Operation = {
'Introspect the calling identity. Returns one of three transport shapes: ' +
'{transport: "oauth", client_id, client_name, scopes, expires_at}, ' +
'{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 ' +
'context is ambiguous (remote=true without auth and no transport marker) ' +
'— fail-closed posture mirroring the v0.26.9 trust-boundary contract.',
'{transport: "local", scopes: []}. Throws unknown_transport when the ' +
'context is ambiguous (remote=true without auth) — fail-closed posture ' +
'mirroring the v0.26.9 trust-boundary contract.',
params: {},
scope: 'read',
handler: async (ctx) => {
@@ -3737,12 +3727,6 @@ const whoami: Operation = {
if (ctx.remote === false) {
return { transport: 'local', scopes: [] };
}
// #1061: stdio MCP is remote/untrusted by design but has no per-token
// auth (local pipe) — a known transport, not a bug. Report it instead of
// throwing. Empty scopes: nothing here may be used to gate anything.
if (!ctx.auth && ctx.transport === 'stdio') {
return { transport: 'stdio', scopes: [] };
}
if (!ctx.auth) {
throw new OperationError(
'unknown_transport',
-7
View File
@@ -32,12 +32,6 @@ export interface DispatchOpts {
remote?: boolean;
/** Override the default stderr logger (e.g. CLI uses console.* directly). */
logger?: OperationContext['logger'];
/**
* #1061: transport marker for auth-less remote surfaces. The stdio MCP
* server passes 'stdio' so identity ops (whoami) can report the transport
* instead of throwing unknown_transport. Never used for trust decisions.
*/
transport?: OperationContext['transport'];
/**
* v0.28: per-token allow-list for the takes.holder field. Threaded by
* the HTTP/stdio transport from `access_tokens.permissions.takes_holders`.
@@ -209,7 +203,6 @@ export function buildOperationContext(
logger: opts.logger || stderrLogger,
dryRun: !!params.dry_run,
remote: opts.remote ?? true,
transport: opts.transport,
takesHoldersAllowList: opts.takesHoldersAllowList,
// v0.34 D4: sourceId is REQUIRED at the type level. Auto-fill 'default'
// for single-source brains and any caller who didn't resolve a sourceId.
-4
View File
@@ -42,10 +42,6 @@ export async function startMcpServer(engine: BrainEngine) {
// `gbrain call <op>` (sets remote=false in src/cli.ts).
return dispatchToolCall(engine, name, params, {
remote: true,
// #1061: mark the transport so whoami can report {transport: 'stdio'}
// instead of throwing unknown_transport. Trust posture unchanged —
// stdio stays remote/untrusted.
transport: 'stdio',
takesHoldersAllowList: ['world'],
// v0.31: source defaults to 'default' for stdio (no per-token scope).
// Operators who want a different source on stdio MCP should set
+14
View File
@@ -54,6 +54,20 @@ describe('autopilot.ts ↔ dispatchPerSource wiring', () => {
expect(AUTOPILOT_SRC).toMatch(/lastFullCycleAt\s*=\s*Date\.now\(\)/);
});
test('stale per-source cycle freshness is a shouldFullCycle input (#2060)', () => {
// Targeted mode (score 7094, plan ≤3, est <300s) must not be able to
// starve per-source cycle dispatch: a stale source (per countStaleSources
// over listAllSources) forces the fanout path, and the sleep gate must
// not fire while stale sources exist. Without these terms, cycle
// freshness never advances for a brain that always lands in targeted mode.
expect(AUTOPILOT_SRC).toMatch(/countStaleSources/);
const fullCycleDeclIdx = AUTOPILOT_SRC.indexOf('const shouldFullCycle');
expect(fullCycleDeclIdx).toBeGreaterThan(-1);
const decl = AUTOPILOT_SRC.slice(fullCycleDeclIdx, fullCycleDeclIdx + 700);
expect(decl).toMatch(/staleCycleSources\s*>\s*0/);
expect(decl).toMatch(/const shouldSleep[^;]*staleCycleSources\s*===\s*0/);
});
test('does NOT regress to the single-job dispatch on the full-cycle path', () => {
// Pre-PR: the shouldFullCycle branch did:
// const job = await queue.add('autopilot-cycle', { repoPath }, {
+18
View File
@@ -14,6 +14,7 @@ import { describe, test, expect } from 'bun:test';
import {
readLastFullCycleAt,
isSourceStale,
countStaleSources,
selectSourcesForDispatch,
resolveFanoutMax,
dispatchPerSource,
@@ -74,6 +75,23 @@ describe('isSourceStale', () => {
});
});
describe('countStaleSources (#2060 dispatch-decision input)', () => {
const NOW = Date.parse('2026-05-22T12:00:00.000Z');
test('counts never-cycled + past-floor sources, ignores fresh', () => {
const sources = [
src('never-cycled'), // stale (null)
src('old', new Date(NOW - 2 * 60 * 60_000).toISOString()), // stale (2h)
src('fresh', new Date(NOW - 30 * 60_000).toISOString()), // fresh (30min)
];
expect(countStaleSources(sources, NOW)).toBe(2);
});
test('returns 0 for all-fresh and for empty list', () => {
const fresh = src('a', new Date(NOW - 10 * 60_000).toISOString());
expect(countStaleSources([fresh], NOW)).toBe(0);
expect(countStaleSources([], NOW)).toBe(0);
});
});
describe('selectSourcesForDispatch', () => {
const NOW = Date.parse('2026-05-22T12:00:00.000Z');
const fresh = (id: string, agoMin: number) =>
+38 -8
View File
@@ -3,8 +3,10 @@
* cycles. Closes codex round-1 P0-5 (write site for last_full_cycle_at
* was unspecified pre-PR).
*
* Conditions for write:
* - opts.sourceId is set (legacy callers without sourceId skip the write)
* Conditions for write (keyed off `cycleSourceId` = opts.sourceId ?? the
* source resolved from brainDir, so the autopilot's inline cycle brainDir
* set, no explicit sourceId also advances the timestamp, #1993):
* - a source resolves (explicit sourceId, or brainDir matches a source)
* - engine is non-null (no-DB path skips)
* - status is 'ok' | 'clean' | 'partial' (failed/skipped don't mark fresh)
* - dryRun is false
@@ -90,17 +92,45 @@ describe('runCycle last_full_cycle_at exit hook', () => {
});
});
test('legacy caller (no sourceId) does NOT write any source timestamp', async () => {
test('no explicit sourceId but brainDir resolves a source → writes the resolved source timestamp', async () => {
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
await seedSource('default-like');
// No sourceId passed; should remain untouched.
// The autopilot's inline cycle sets brainDir but passes no sourceId.
// runCycle resolves the source from brainDir (local_path match) into
// cycleSourceId and stamps last_full_cycle_at for it — otherwise
// cycle_freshness reports the brain stale even while the autopilot
// cycles every interval (#1993).
await seedSource('resolved-from-dir'); // local_path = brainDir
expect(await readLastFullCycleAt('resolved-from-dir')).toBeNull();
const t0 = Date.now();
const report = await runCycle(engine, {
brainDir,
phases: ['lint'],
});
expect(['ok', 'clean']).toContain(report.status);
const after = await readLastFullCycleAt('resolved-from-dir');
expect(after).not.toBeNull();
expect(new Date(after!).getTime()).toBeGreaterThanOrEqual(t0);
});
});
test('no sourceId and brainDir matches no source → does not write', async () => {
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
// A source exists but its local_path does NOT match brainDir, so
// resolveSourceForDir returns undefined, cycleSourceId is undefined,
// and no per-source timestamp is written.
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
VALUES ('unmatched', 'unmatched', '/no/such/repo', '{}'::jsonb, false, NOW())
ON CONFLICT (id) DO UPDATE SET local_path = EXCLUDED.local_path`,
[],
);
await runCycle(engine, {
brainDir,
phases: ['lint'],
});
// No per-source write happens; default source's config stays empty.
const after = await readLastFullCycleAt('default-like');
expect(after).toBeNull();
expect(await readLastFullCycleAt('unmatched')).toBeNull();
});
});
+1 -45
View File
@@ -12,7 +12,7 @@
*/
import { describe, test, expect } from 'bun:test';
import { groupReadyByProvider, findEnvKeyTypos, seedAIOptionsFromConfig } from '../src/commands/init.ts';
import { groupReadyByProvider, findEnvKeyTypos } from '../src/commands/init.ts';
describe('groupReadyByProvider — embedding touchpoint', () => {
test('OPENAI_API_KEY alone → openai is ready', async () => {
@@ -149,47 +149,3 @@ describe('findEnvKeyTypos', () => {
expect(got.find(t => t.userSet === 'COMPLETELY_UNRELATED_KEY')).toBeUndefined();
});
});
describe('seedAIOptionsFromConfig — #1058 cold-install env fallback', () => {
test('null config (no config.json, no DATABASE_URL) falls back to GBRAIN_* env vars', () => {
const got = seedAIOptionsFromConfig(null, {
GBRAIN_EMBEDDING_MODEL: 'voyage:voyage-3-large',
GBRAIN_EMBEDDING_DIMENSIONS: '1024',
GBRAIN_EXPANSION_MODEL: 'openai:gpt-5-mini',
GBRAIN_CHAT_MODEL: 'anthropic:claude-sonnet-4-6',
});
expect(got.embedding_model).toBe('voyage:voyage-3-large');
expect(got.embedding_dimensions).toBe(1024);
expect(got.expansion_model).toBe('openai:gpt-5-mini');
expect(got.chat_model).toBe('anthropic:claude-sonnet-4-6');
});
test('null config + no env vars → empty seed (Tier-3 detection takes over)', () => {
const got = seedAIOptionsFromConfig(null, {});
expect(got).toEqual({});
});
test('persisted config wins (loadConfig already merged env when non-null)', () => {
const got = seedAIOptionsFromConfig(
{ engine: 'pglite', embedding_model: 'openai:text-embedding-3-small', embedding_dimensions: 1536 } as any,
{ GBRAIN_EMBEDDING_MODEL: 'voyage:voyage-3-large' },
);
expect(got.embedding_model).toBe('openai:text-embedding-3-small');
expect(got.embedding_dimensions).toBe(1536);
});
test('embedding_disabled sentinel honored on re-init', () => {
const got = seedAIOptionsFromConfig({ engine: 'pglite', embedding_disabled: true } as any, {});
expect(got.noEmbedding).toBe(true);
expect(got.embedding_model).toBeUndefined();
});
test('non-numeric GBRAIN_EMBEDDING_DIMENSIONS ignored, model still seeds', () => {
const got = seedAIOptionsFromConfig(null, {
GBRAIN_EMBEDDING_MODEL: 'voyage:voyage-3-large',
GBRAIN_EMBEDDING_DIMENSIONS: 'not-a-number',
});
expect(got.embedding_model).toBe('voyage:voyage-3-large');
expect(got.embedding_dimensions).toBeUndefined();
});
});
-29
View File
@@ -94,35 +94,6 @@ describe('whoami op contract', () => {
expect(result.expires_at).toBeNull();
});
// #1061: stdio MCP is remote/untrusted by design but has no per-token auth
// (local pipe). The stdio dispatch marks ctx.transport='stdio'; whoami
// reports it instead of throwing unknown_transport.
test('stdio transport (remote=true, no auth, transport marker) reports stdio', async () => {
const result = (await whoami.handler(
ctxWith({ remote: true, auth: undefined, transport: 'stdio' }),
{},
)) as any;
expect(result.transport).toBe('stdio');
expect(result.scopes).toEqual([]);
});
test('stdio marker does not mask real auth (auth still wins)', async () => {
const result = (await whoami.handler(
ctxWith({
remote: true,
transport: 'stdio',
auth: {
token: 'gbrain_at_xxx',
clientId: 'gbrain_cl_abc',
scopes: ['read'],
expiresAt: 1,
} as AuthInfo,
}),
{},
)) as any;
expect(result.transport).toBe('oauth');
});
// Q3: ambiguous transport — fail-closed. The footgun this guards against
// is a future transport that lands without threading auth, where a buggy
// caller might trust whoami's output to gate sensitive ops.