Compare commits

..
Author SHA1 Message Date
08f2397615 fix(webhook): extract links for incremental push syncs (#2849)
GitHub webhook + sync-trigger 'sync' jobs never opted into extraction:
the standalone sync handler defaults noExtract to TRUE, so webhook-
imported pages advanced the source bookmark with links_extracted_at
unstamped — and nothing behind a standalone job ever swept them (the
autopilot cycle's extract phase only walks that cycle's changedSlugs).

Fix, both halves of the issue:
- webhook + sync-trigger payloads pass noExtract:false so normal
  incremental pushes get sync's inline link/timeline extraction.
- >100-file deferral branch: performSync surfaces extractDeferred, and
  the sync job handler queues a source-scoped extract{stale:true}
  follow-up job (idempotent sweep; extract handler gains a stale mode
  backed by the same extractStaleFromDB core as `gbrain extract --stale`).

Tests: new sync-large-deferral-extract.serial.test.ts drives a real
101-file incremental sync end-to-end (deferral flag → queued follow-up →
stale sweep recovers links + watermark); webhook payload contract test +
sync-trigger noExtract assertion salvaged from #2850.

Fixes #2849
Takeover of #2850

Co-authored-by: patentsong <patentsong@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:38:43 -07:00
13 changed files with 240 additions and 151 deletions
+1 -1
View File
@@ -1651,7 +1651,7 @@ async function extractTimelineFromDB(
* make re-extraction idempotent). EVERY processed page is stamped, including
* zero-link pages — they WERE processed.
*/
async function extractStaleFromDB(
export async function extractStaleFromDB(
engine: BrainEngine,
opts: {
dryRun: boolean;
+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 ---------------------------------------------
+39 -1
View File
@@ -1479,7 +1479,31 @@ export async function registerBuiltinHandlers(
embedSkipReason = 'auto_embed_disabled';
}
return { ...result, embed_job_id: embedJobId, embed_skip_reason: embedSkipReason };
// #2849: large-sync extract deferral follow-up. performSync skips inline
// link/timeline extraction when totalChanges > 100, leaving
// links_extracted_at unstamped. A standalone sync job (webhook push,
// sync trigger) has no autopilot extract phase behind it, so the pages
// would stay extraction-stale until a manual `gbrain extract --stale`.
// Queue a source-scoped stale sweep instead. Best-effort + idempotent:
// a duplicate sweep finds 0 stale pages and no-ops.
let extractJobId: number | null = null;
if (result.extractDeferred) {
try {
const { MinionQueue } = await import('../core/minions/queue.ts');
const queue = new MinionQueue(engine);
const followUp = await queue.add(
'extract',
{ stale: true, ...(sourceId ? { sourceId } : {}) },
{
idempotency_key: `sync-extract-stale:${sourceId ?? 'default'}:${Math.floor(Date.now() / 30_000)}`,
maxWaiting: 1,
},
);
extractJobId = followUp.id;
} catch { /* best-effort: extract --stale sweeps it later */ }
}
return { ...result, embed_job_id: embedJobId, embed_skip_reason: embedSkipReason, extract_stale_job_id: extractJobId };
});
registerBuiltinJob(worker, engine, 'embed', async (job) => {
@@ -1652,6 +1676,20 @@ export async function registerBuiltinHandlers(
});
worker.register('extract', async (job) => {
// #2849: stale-sweep mode — the sync handler's large-sync deferral
// follow-up. DB-source (reads page content from the DB, so it runs on
// checkout-less brains), source-scopable, idempotent. Same core as
// `gbrain extract --stale`.
if (job.data.stale === true) {
const { extractStaleFromDB } = await import('./extract.ts');
return await extractStaleFromDB(engine, {
dryRun: !!job.data.dryRun,
jsonMode: false,
includeFrontmatter: false,
sourceIdFilter: typeof job.data.sourceId === 'string' ? job.data.sourceId : undefined,
catchUp: false,
});
}
const { runExtractCore } = await import('./extract.ts');
const mode = (typeof job.data.mode === 'string' && ['links', 'timeline', 'all'].includes(job.data.mode))
? (job.data.mode as 'links' | 'timeline' | 'all')
+8 -2
View File
@@ -2146,8 +2146,13 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// Other event types (ping, pull_request, etc.) return 202 'ignored'
// so GitHub doesn't retry.
// D15.5: HMAC compare uses the shared safeHexEqual helper.
// D18: submits 'sync' job with auto_embed_backfill=true and priority -10
// (above autopilot's 0).
// D18: submits 'sync' job with extraction + auto_embed_backfill enabled and
// priority -10 (above autopilot's 0). noExtract:false opts normal
// incremental pushes into sync's inline link/timeline extraction (#2849
// — the standalone sync handler defaults noExtract to TRUE, which left
// webhook-imported pages permanently stale). Large (>100 file) pushes
// defer inline extract; the sync handler queues an extract --stale
// follow-up job for that branch.
// ---------------------------------------------------------------------------
const githubWebhookLimiter = rateLimit({
windowMs: 60_000,
@@ -2267,6 +2272,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
'sync',
{
sourceId: source.id,
noExtract: false,
auto_embed_backfill: true,
embed_reason: 'webhook',
},
+19 -1
View File
@@ -222,6 +222,14 @@ export interface SyncResult {
* everything," the exact misdiagnosis in the #1794 recurrence report.
*/
bankedFiles?: number;
/**
* #2849: true when extraction was REQUESTED (noExtract false) but this sync
* skipped inline link/timeline extraction because totalChanges > 100 (the
* #1794 large-sync deferral). links_extracted_at stays unstamped for the
* imported pages. The standalone `sync` job handler queues a source-scoped
* `extract --stale` follow-up when set; CLI runs print the manual hint.
*/
extractDeferred?: boolean;
}
/**
@@ -1379,6 +1387,10 @@ See also:
{
sourceId: sourceIdArg,
repoPath: source.local_path,
// #2849: opt in to inline extraction — the standalone sync handler
// defaults noExtract to TRUE (dedupe for doctor's [sync, extract]
// remediation plan), which would leave triggered syncs extraction-stale.
noExtract: false,
auto_embed_backfill: true,
embed_reason: 'sync_trigger',
},
@@ -3287,11 +3299,16 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// the stale sweep scans the whole source, so banked-across-runs pages are
// covered regardless.
const extractOpts = opts.sourceId ? { sourceId: opts.sourceId } : undefined;
let extractDeferred = false;
if (!opts.noExtract && totalChanges > 100 && pagesAffected.length > 0) {
// #2849: surface the deferral to callers. A standalone sync job (webhook
// push, sync trigger) has no autopilot extract phase behind it, so the
// job handler queues an `extract --stale` follow-up off this flag.
extractDeferred = true;
slog(
` Large sync: deferring link/timeline extraction. ` +
`Run 'gbrain extract --stale${opts.sourceId ? ` --source-id ${opts.sourceId}` : ''}' ` +
`(or let the autopilot cycle's extract phase sweep it).`,
`(sync jobs queue this follow-up automatically).`,
);
}
if (!opts.noExtract && totalChanges <= 100 && pagesAffected.length > 0) {
@@ -3400,6 +3417,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
chunksCreated,
embedded,
pagesAffected,
extractDeferred,
};
}
+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
+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();
});
});
+23
View File
@@ -16,6 +16,7 @@
*/
import { describe, test, expect } from 'bun:test';
import { createHmac } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { safeHexEqual } from '../src/core/timing-safe.ts';
const GITHUB_SECRET = 'super-secret-webhook-key';
@@ -123,3 +124,25 @@ describe('Branch ref construction (D5)', () => {
expect(pushedRef === `refs/heads/${trackedBranch}`).toBe(false);
});
});
describe('Webhook sync job extraction contract (#2849)', () => {
test('opts into extraction before the pushed commit is consumed', () => {
const serveSource = readFileSync(
new URL('../src/commands/serve-http.ts', import.meta.url),
'utf8',
);
const routeStart = serveSource.indexOf("'/webhooks/github'");
const queueStart = serveSource.indexOf('const job = await queue.add(', routeStart);
const responseStart = serveSource.indexOf('res.status(202)', queueStart);
expect(routeStart).toBeGreaterThanOrEqual(0);
expect(queueStart).toBeGreaterThan(routeStart);
expect(responseStart).toBeGreaterThan(queueStart);
const routeSource = serveSource.slice(queueStart, responseStart);
const payload = routeSource.match(
/queue\.add\(\s*'sync',\s*\{([\s\S]*?)\}\s*,\s*\{/,
);
expect(payload).not.toBeNull();
expect(payload?.[1]).toMatch(/\bnoExtract:\s*false\b/);
});
});
@@ -0,0 +1,130 @@
/**
* #2849 large-sync extract deferral queues an `extract --stale` follow-up.
*
* performSync's incremental path skips inline link/timeline extraction when
* totalChanges > 100 (the #1794 large-sync deferral), leaving
* links_extracted_at unstamped. Pre-fix, a standalone sync job (webhook push,
* `gbrain sync trigger`) had NOTHING behind it to sweep those pages the
* autopilot cycle's extract phase only walks that cycle's changedSlugs so a
* large webhook push left extraction permanently stale until a manual
* `gbrain extract --stale`.
*
* Pins:
* (a) performSync surfaces `extractDeferred: true` on the >100 branch and
* leaves the pages unstamped/unlinked.
* (b) the `sync` job handler queues an `extract` job with
* { stale: true, sourceId? } when extractDeferred is set.
* (c) the `extract` handler's stale mode actually sweeps: links created +
* watermark stamped (end-to-end recovery, no manual step).
*
* Marked .serial.test.ts spawns git subprocesses + shares one PGLite engine.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { join } from 'path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { MinionWorker } from '../src/core/minions/worker.ts';
import { MinionQueue } from '../src/core/minions/queue.ts';
import { registerBuiltinHandlers } from '../src/commands/jobs.ts';
let engine: PGLiteEngine;
let worker: MinionWorker;
let repoPath: string;
function git(cmd: string): void { execSync(cmd, { cwd: repoPath, stdio: 'pipe' }); }
describe('#2849 — large sync defers extract and queues a stale sweep', () => {
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
worker = new MinionWorker(engine, { queue: 'test' });
await registerBuiltinHandlers(worker, engine, { quiet: true });
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-large-defer-'));
git('git init');
git('git config user.email "t@t.com"');
git('git config user.name "T"');
mkdirSync(join(repoPath, 'people'), { recursive: true });
mkdirSync(join(repoPath, 'notes'), { recursive: true });
writeFileSync(join(repoPath, 'people/alice.md'), [
'---', 'type: person', 'title: Alice', '---', '', 'Alice is a founder.',
].join('\n'));
git('git add -A && git commit -m "initial"');
// Seed: full first sync imports the anchor page + sets last_commit.
const { performSync } = await import('../src/commands/sync.ts');
await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true });
// Second commit: 101 new pages → incremental totalChanges > 100.
for (let i = 0; i < 101; i++) {
writeFileSync(join(repoPath, `notes/n${i}.md`), [
'---', 'type: note', `title: Note ${i}`, '---', '',
`[Alice](people/alice) appears in note ${i}.`,
].join('\n'));
}
git('git add -A && git commit -m "add 101 pages"');
}, 120_000);
afterAll(async () => {
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
if (engine) await engine.disconnect();
}, 60_000);
test('sync handler defers inline extract and queues extract{stale} follow-up; stale sweep recovers', async () => {
const syncHandler = (worker as unknown as { handlers: Map<string, (job: unknown) => Promise<unknown>> })
.handlers.get('sync');
expect(syncHandler).toBeDefined();
// Same payload shape the webhook submits (minus embed backfill noise).
const result = await syncHandler!({
data: { repoPath, noExtract: false, noPull: true, auto_embed_backfill: false },
signal: { aborted: false },
updateProgress: async () => {},
}) as { status: string; extractDeferred?: boolean; extract_stale_job_id?: number | null };
expect(result.status).toBe('synced');
// (a) inline extract was deferred, pages left stale.
expect(result.extractDeferred).toBe(true);
const staleBefore = await engine.countStalePagesForExtraction();
expect(staleBefore).toBeGreaterThan(100);
expect(await engine.getLinks('notes/n0')).toHaveLength(0);
// (b) a follow-up extract job with stale:true was queued.
expect(result.extract_stale_job_id).toBeGreaterThan(0);
const queue = new MinionQueue(engine);
const extractJobs = await queue.getJobs({ name: 'extract', limit: 5 });
expect(extractJobs.length).toBe(1);
expect((extractJobs[0].data as { stale: boolean }).stale).toBe(true);
// (c) running the extract handler's stale mode recovers: links + stamps.
const extractHandler = (worker as unknown as { handlers: Map<string, (job: unknown) => Promise<unknown>> })
.handlers.get('extract');
await extractHandler!({
data: extractJobs[0].data,
signal: { aborted: false },
updateProgress: async () => {},
});
const links = await engine.getLinks('notes/n0');
expect(links.some(l => l.to_slug === 'people/alice')).toBe(true);
const rows = await engine.executeRaw<{ links_extracted_at: string | null }>(
`SELECT links_extracted_at FROM pages WHERE slug = 'notes/n0'`,
);
expect(rows[0]?.links_extracted_at).not.toBeNull();
}, 180_000);
test('sub-threshold sync does NOT set extractDeferred (no spurious follow-up)', async () => {
// One more small commit → inline extract path, no deferral.
writeFileSync(join(repoPath, 'notes/small.md'), [
'---', 'type: note', 'title: Small', '---', '', 'No big deal.',
].join('\n'));
git('git add -A && git commit -m "one small page"');
const { performSync } = await import('../src/commands/sync.ts');
const result = await performSync(engine, { repoPath, noPull: true, noEmbed: true });
expect(result.status).toBe('synced');
expect(result.extractDeferred).toBeFalsy();
}, 60_000);
});
+4
View File
@@ -100,6 +100,10 @@ describe('runSyncTrigger', () => {
const job = jobs[0];
expect(job.priority).toBe(-10);
expect((job.data as { sourceId: string }).sourceId).toBe('default');
// #2849: opt in to inline extraction — the standalone sync handler
// defaults noExtract to TRUE, which would leave triggered syncs
// extraction-stale.
expect((job.data as { noExtract: boolean }).noExtract).toBe(false);
expect((job.data as { auto_embed_backfill: boolean }).auto_embed_backfill).toBe(true);
});
-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.