mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-18 09:48:17 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea041756ae | ||
|
|
264798c082 |
@@ -76,9 +76,9 @@ FLAGS:
|
||||
dimensions (goal, depth, sourcing, specificity, useful).
|
||||
--cycles N 1-3. Default: 3 in TTY, 1 in non-TTY (T11). Each
|
||||
cycle is 3 model calls; verdict aggregates over them.
|
||||
--slot-a-model <id> Override default 'openai:gpt-4o'.
|
||||
--slot-b-model <id> Override default 'anthropic:claude-opus-4-7'.
|
||||
--slot-c-model <id> Override default 'google:gemini-1.5-pro'.
|
||||
--slot-a-model <id> Override default 'openai:gpt-5.2'.
|
||||
--slot-b-model <id> Override default 'anthropic:claude-opus-4-8'.
|
||||
--slot-c-model <id> Override default 'google:gemini-3-pro'.
|
||||
--receipt-dir <path> Default: gbrainPath('eval-receipts').
|
||||
--max-tokens N Output token budget per call. Default: 4000.
|
||||
--json Emit final aggregate as JSON to stdout (progress to stderr).
|
||||
|
||||
+12
-42
@@ -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 ---------------------------------------------
|
||||
|
||||
@@ -23,11 +23,20 @@ export const google: Recipe = {
|
||||
price_last_verified: '2026-04-20',
|
||||
},
|
||||
chat: {
|
||||
models: ['gemini-2.0-flash-exp', 'gemini-2.0-flash', 'gemini-1.5-pro'],
|
||||
// gemini-1.5-pro and gemini-2.0-flash-exp 404 at the live API for new
|
||||
// keys (#1607); gemini-2.0-flash kept for existing keys. gemini-3-pro
|
||||
// matches the registry alias in model-config.ts:DEFAULT_ALIASES.
|
||||
models: [
|
||||
'gemini-3-pro',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-flash-latest',
|
||||
'gemini-2.0-flash',
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_subagent_loop: true,
|
||||
supports_prompt_cache: false,
|
||||
max_context_tokens: 1000000, // Gemini 1.5 Pro
|
||||
max_context_tokens: 1000000, // Gemini 2.5/3 Pro
|
||||
cost_per_1m_input_usd: 0.30,
|
||||
cost_per_1m_output_usd: 1.20,
|
||||
price_last_verified: '2026-04-20',
|
||||
|
||||
@@ -30,7 +30,9 @@ export const openai: Recipe = {
|
||||
price_last_verified: '2026-04-20',
|
||||
},
|
||||
chat: {
|
||||
models: ['gpt-5.2', 'gpt-4o-mini'],
|
||||
// gpt-5 matches the registry alias in model-config.ts:DEFAULT_ALIASES;
|
||||
// gpt-4o/gpt-5.5 are live API models carried in model-pricing.ts (#1607).
|
||||
models: ['gpt-5.2', 'gpt-5.5', 'gpt-5', 'gpt-4o', 'gpt-4o-mini'],
|
||||
supports_tools: true,
|
||||
supports_subagent_loop: true,
|
||||
supports_prompt_cache: false,
|
||||
|
||||
@@ -44,9 +44,9 @@ export const DEFAULT_DIMENSIONS: string[] = [
|
||||
* `--slot-a-model`, `--slot-b-model`, `--slot-c-model` on the CLI.
|
||||
*/
|
||||
export const DEFAULT_SLOTS: SlotConfig[] = [
|
||||
{ id: 'A', model: 'openai:gpt-4o' },
|
||||
{ id: 'B', model: 'anthropic:claude-opus-4-7' },
|
||||
{ id: 'C', model: 'google:gemini-1.5-pro' },
|
||||
{ id: 'A', model: 'openai:gpt-5.2' },
|
||||
{ id: 'B', model: 'anthropic:claude-opus-4-8' },
|
||||
{ id: 'C', model: 'google:gemini-3-pro' },
|
||||
];
|
||||
|
||||
export interface SlotConfig {
|
||||
|
||||
@@ -76,9 +76,13 @@ export const CANONICAL_PRICING: Record<string, ModelPricing> = {
|
||||
'openai:gpt-4o-mini': { input: 0.15, output: 0.60 },
|
||||
'openai:gpt-5': { input: 5.00, output: 20.00 },
|
||||
'openai:gpt-5.5': { input: 4.00, output: 16.00 },
|
||||
// gpt-5.2 baseline — matches the openai recipe's chat cost anchors.
|
||||
'openai:gpt-5.2': { input: 1.25, output: 10.00 },
|
||||
|
||||
// ── Google ─────────────────────────────────────────────────────────────
|
||||
'google:gemini-1.5-pro': { input: 1.25, output: 5.00 },
|
||||
// Gemini 3 Pro (≤200K context rate) — the cross-modal slot C default.
|
||||
'google:gemini-3-pro': { input: 2.00, output: 12.00 },
|
||||
// Gemini 2.0 Flash: $0.10 in / $0.40 out (verified 2026-06-03). Reconciled
|
||||
// from a stale $0.30/$1.20 entry that had drifted in takes-quality-eval.
|
||||
// `gemini-2-flash` kept as an alias for the legacy id spelling.
|
||||
|
||||
+3
-19
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,7 +45,7 @@ afterEach(() => {
|
||||
|
||||
function makeChatStub(scoresBySlot: Record<string, number[]>) {
|
||||
let callIdx = 0;
|
||||
const order = ['openai:gpt-4o', 'anthropic:claude-opus-4-7', 'google:gemini-1.5-pro'];
|
||||
const order = ['openai:gpt-5.2', 'anthropic:claude-opus-4-8', 'google:gemini-3-pro'];
|
||||
return mock(async (opts: { model?: string }) => {
|
||||
const model = opts.model ?? '';
|
||||
callIdx++;
|
||||
@@ -74,9 +74,9 @@ function makeChatStub(scoresBySlot: Record<string, number[]>) {
|
||||
describe('gbrain eval cross-modal — runner verdict contract', () => {
|
||||
test('PASS: 3 happy responses, all dims >=7', async () => {
|
||||
const chatStub = makeChatStub({
|
||||
'openai:gpt-4o': [9, 8],
|
||||
'anthropic:claude-opus-4-7': [8, 7],
|
||||
'google:gemini-1.5-pro': [8, 8],
|
||||
'openai:gpt-5.2': [9, 8],
|
||||
'anthropic:claude-opus-4-8': [8, 7],
|
||||
'google:gemini-3-pro': [8, 8],
|
||||
});
|
||||
mock.module('../../src/core/ai/gateway.ts', () => ({
|
||||
chat: chatStub,
|
||||
@@ -105,9 +105,9 @@ describe('gbrain eval cross-modal — runner verdict contract', () => {
|
||||
|
||||
test('FAIL: one dim mean below 7', async () => {
|
||||
const chatStub = makeChatStub({
|
||||
'openai:gpt-4o': [9, 6],
|
||||
'anthropic:claude-opus-4-7': [8, 6],
|
||||
'google:gemini-1.5-pro': [8, 6],
|
||||
'openai:gpt-5.2': [9, 6],
|
||||
'anthropic:claude-opus-4-8': [8, 6],
|
||||
'google:gemini-3-pro': [8, 6],
|
||||
});
|
||||
mock.module('../../src/core/ai/gateway.ts', () => ({
|
||||
chat: chatStub,
|
||||
@@ -130,9 +130,9 @@ describe('gbrain eval cross-modal — runner verdict contract', () => {
|
||||
|
||||
test('FAIL: min-score floor caught when one model scores <5 (Q2)', async () => {
|
||||
const chatStub = makeChatStub({
|
||||
'openai:gpt-4o': [9, 8],
|
||||
'anthropic:claude-opus-4-7': [8, 8],
|
||||
'google:gemini-1.5-pro': [4, 8], // goal=4 trips the floor
|
||||
'openai:gpt-5.2': [9, 8],
|
||||
'anthropic:claude-opus-4-8': [8, 8],
|
||||
'google:gemini-3-pro': [4, 8], // goal=4 trips the floor
|
||||
});
|
||||
mock.module('../../src/core/ai/gateway.ts', () => ({
|
||||
chat: chatStub,
|
||||
@@ -155,7 +155,7 @@ describe('gbrain eval cross-modal — runner verdict contract', () => {
|
||||
|
||||
test('INCONCLUSIVE: 2 of 3 mock 5xx -> exit 2 contract (Q3)', async () => {
|
||||
const chatStub = mock(async (opts: { model?: string }) => {
|
||||
if (opts.model === 'openai:gpt-4o') {
|
||||
if (opts.model === 'openai:gpt-5.2') {
|
||||
return {
|
||||
text: JSON.stringify({
|
||||
scores: { goal: { score: 8 } },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Anti-rot guard (#1607): every hardcoded default model string shipped in
|
||||
* source code must pass the native-recipe chat allowlist. Hardcoded defaults
|
||||
* do NOT get the config `extendedModels` escape hatch in assertTouchpoint,
|
||||
* so a default that drifts out of its recipe's allowlist throws "not listed"
|
||||
* at runtime — exactly how the eval cross-modal defaults rotted (gpt-4o was
|
||||
* the shipped slot-A default while the openai chat allowlist rejected it).
|
||||
*
|
||||
* Covers: cross-modal DEFAULT_SLOTS, model-config DEFAULT_ALIASES and
|
||||
* TIER_DEFAULTS. Also asserts DEFAULT_SLOTS models are priced from the
|
||||
* canonical table so cost estimates don't silently go blind.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { DEFAULT_SLOTS } from '../src/core/cross-modal-eval/runner.ts';
|
||||
import { DEFAULT_ALIASES, TIER_DEFAULTS } from '../src/core/model-config.ts';
|
||||
import { resolveRecipe, assertTouchpoint } from '../src/core/ai/model-resolver.ts';
|
||||
import { canonicalLookup } from '../src/core/model-pricing.ts';
|
||||
|
||||
function expectInChatAllowlist(model: string, label: string) {
|
||||
const { parsed, recipe } = resolveRecipe(model);
|
||||
expect(
|
||||
() => assertTouchpoint(recipe, 'chat', parsed.modelId),
|
||||
`${label} default "${model}" is not in the ${recipe.id} chat allowlist`,
|
||||
).not.toThrow();
|
||||
}
|
||||
|
||||
describe('shipped default models pass native chat allowlists (#1607)', () => {
|
||||
test('eval cross-modal DEFAULT_SLOTS', () => {
|
||||
for (const slot of DEFAULT_SLOTS) {
|
||||
expectInChatAllowlist(slot.model, `slot ${slot.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('eval cross-modal DEFAULT_SLOTS are priced from canonical', () => {
|
||||
for (const slot of DEFAULT_SLOTS) {
|
||||
expect(canonicalLookup(slot.model), `${slot.model} has no canonical pricing`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('model-config DEFAULT_ALIASES', () => {
|
||||
for (const [alias, model] of Object.entries(DEFAULT_ALIASES)) {
|
||||
expectInChatAllowlist(model, `alias "${alias}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test('model-config TIER_DEFAULTS', () => {
|
||||
for (const [tier, model] of Object.entries(TIER_DEFAULTS)) {
|
||||
expectInChatAllowlist(model, `tier "${tier}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -118,10 +118,13 @@ describe('DRIFT GUARD — derived views stay equal to canonical (re-hardcode tri
|
||||
for (const id of [
|
||||
'openai:gpt-4o',
|
||||
'openai:gpt-4o-mini',
|
||||
'openai:gpt-5.2',
|
||||
'anthropic:claude-opus-4-7',
|
||||
'anthropic:claude-opus-4-8',
|
||||
'anthropic:claude-sonnet-4-6',
|
||||
'google:gemini-1.5-pro',
|
||||
'google:gemini-2.0-flash',
|
||||
'google:gemini-3-pro',
|
||||
'together:meta-llama/Llama-3.3-70B-Instruct-Turbo',
|
||||
'deepseek:deepseek-chat',
|
||||
]) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user