mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(mcp): skip stdin EOF handlers when MCP_STDIO=1 OpenClaw's bundle-mcp gateway and similar wrappers pipe the JSON-RPC handshake on stdin then close their stdin half. Pre-fix, both stdin 'end' and 'close' listeners (server.ts:65-66 and serve.ts:204-206) treated this as a permanent disconnect and shut the server down before the first tool call arrived. Guard both sites with `process.env.MCP_STDIO !== '1'`. Signal handlers (SIGTERM/SIGINT/SIGHUP), transport.onclose, and the parent-process watchdog still cover legitimate shutdown paths. The serve.ts site threads the env read through an injectable `mcpStdio?: boolean` on ServeOptions so tests stay isolated (no process.env mutation per scripts/check-test-isolation.sh R1). Tests: 3 new cases in test/serve-stdio-lifecycle.test.ts pin the guard's invariants — mcpStdio=true must NOT trigger shutdown on stdin EOF, signals must still drive shutdown with mcpStdio=true, and mcpStdio=false (default) preserves existing CLI behavior. 25/25 pass. Origin: PR #870. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(oauth): honor token_endpoint_auth_method=none for PKCE public clients RFC 7591 §3.2.1: when a DCR client declares token_endpoint_auth_method="none" (PKCE-only public clients like Claude Code, Cursor), the authorization server MUST NOT issue a client_secret. Pre-fix, registerClient unconditionally minted a secret, and the MCP SDK's clientAuth middleware then rejected valid public-client flows on /token because it expected client.client_secret to match. Three changes to src/core/oauth-provider.ts:registerClient: - Gate clientSecret generation on isPublicClient = (auth_method === 'none'). Public clients store client_secret_hash = NULL. - Omit client_secret from the response payload for public clients. Confidential clients (default client_secret_post and explicit client_secret_basic) keep their existing one-time-reveal shape. - Normalize NULL secret_hash to JS undefined in getClient so SDK middleware (which checks client.client_secret === undefined, not === null) correctly identifies public clients and skips the secret-comparison branch on /token. Schema is already permissive (client_secret_hash TEXT, no NOT NULL on both src/schema.sql and src/core/pglite-schema.ts) — no migration needed. Tests: 5 new cases in test/oauth.test.ts pin: - public client → no client_secret in response (#11 from plan) - default auth_method → secret unchanged (regression guard) - explicit client_secret_post → secret unchanged - getClient NULL→undefined normalization - PKCE full /authorize → /token end-to-end with no secret (#15 from plan) 69/69 oauth.test.ts cases pass. typecheck clean. Origin: PR #909. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(serve-http): --bind HOST, default to loopback (127.0.0.1) Adds `gbrain serve --http --bind <interface>` to control which network interface the HTTP MCP server listens on. Default flipped from `0.0.0.0` (pre-v0.34) to `127.0.0.1` (v0.34.0+). Why the flip: gbrain's primary use case is a personal-knowledge brain on a laptop. The previous default exposed brains on every interface — one accidental `--http` invocation away from publishing the brain to a LAN. Server operators who need remote access pass `--bind 0.0.0.0` (or a specific interface). Codex's outside-voice on the original PR #864 correctly flagged that the additive flag wasn't actually the fix; the default needed to change for the safety claim to hold. If `--public-url` is set but `--bind` is unset, runServeHttp prints a loud stderr WARN at startup recommending `--bind 0.0.0.0`. Declaring a public URL while quietly binding loopback is almost always a misconfiguration; we want the operator to see it on first start, not silently fail remote requests. Startup banner now includes a `Bind:` row so the listening interface is visible alongside Port / Engine / Issuer. Origin: PR #864, extended with D11 (default flip) per /plan-eng-review codex outside-voice review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mcp): seal source-isolation leak on read path (P0) Pre-fix, an authenticated OAuth MCP client scoped to source-A could enumerate source-B pages via six read-side ops: search, query (text AND image paths), list_pages, traverse_graph, and find_experts. The v0.31.8 source-scoping pattern shipped through dispatch.ts but the op handlers never threaded ctx.sourceId into their engine calls, and hybridSearch.ts:223's explicit SearchOpts rebuild dropped sourceId even when callers passed it. Sealing the leak: - src/core/operations.ts adds sourceScopeOpts(ctx), the canonical precedence ladder: ctx.auth.allowedSources (federated) wins over ctx.sourceId (scalar) wins over nothing. Threaded into all 5 read-side op handlers + the query-image-path searchVector call (the 6th leak surface codex caught in plan review). - src/core/search/hybrid.ts:223 now threads sourceId + sourceIds fields through the inner SearchOpts rebuild. The explicit pick shape is preserved (HNSW inner-CTE ordering depends on it) but extended. - src/core/types.ts adds sourceIds?: string[] to SearchOpts + PageFilters (D9: federated read needs array-shaped engine filter or fan-out; array wins for hot retrieval). - src/core/operations.ts AuthInfo gains sourceId + allowedSources (D2: identity surface symmetric with the federated_read column #876 will add). - Both engines now apply WHERE source_id = $N (scalar) or = ANY($N::text[]) (array) at the SQL layer for searchKeyword, searchKeywordChunks, searchVector, listPages, traverseGraph, traversePaths. Array form wins when both are set. The searchVector filter pushes into the inner HNSW CTE (codex flagged this placement during plan review). - traverseGraph + traversePaths signatures gain opts.sourceId + opts.sourceIds; engine.ts interface updated. - findExperts (the whoknows op, D3 5th leak surface) accepts sourceId + sourceIds and threads them into its internal hybridSearch call. PR #861 was authored before v0.33 shipped so this op wasn't covered in the original PR. Auth wiring: - GBrainOAuthProvider.verifyAccessToken populates AuthInfo.sourceId from oauth_clients.source_id. JOIN guarded by isUndefinedColumnError so pre-v55 brains degrade to legacy projection rather than refusing every token verification. - GBrainOAuthProvider.registerClientManual gains a sourceId parameter (defaults to 'default'). DCR registerClient also sets source_id='default' on the inserted row. - serve-http.ts:929 cleanup: AuthInfo.sourceId is now a real typed field. The cast + GBRAIN_SOURCE env fallback chain is gone (D13). Legacy bearer tokens default to 'default' source in verifyAccessToken. - http-transport.ts (legacy access_tokens path) threads sourceId='default' through DispatchOpts so v0.22.7 callers stay source-scoped. - auth.ts CLI adds --source flag to gbrain auth register-client. Migration v55 (D10 + D13): - ALTER TABLE oauth_clients ADD COLUMN source_id TEXT (nullable). - Backfill UPDATE source_id = 'default' WHERE source_id IS NULL — preserves v0.33 effective behavior verbatim for legacy clients. - ADD CONSTRAINT FK ... REFERENCES sources(id) ON DELETE SET NULL, wrapped in DO block so re-runs against fresh-install brains (where the FK already lives inline in SCHEMA_SQL) no-op cleanly. - CREATE INDEX idx_oauth_clients_source_id WHERE source_id IS NOT NULL for the verifyAccessToken JOIN. - GBRAIN_ACCEPT_SILENT_WIDEN env-flag wired through the runner via SET LOCAL gbrain.accept_silent_widen — reserved for future migrations that hit the silent-widen footgun codex flagged. This migration doesn't need it (column is brand new; no pre-existing stale values possible by definition). - src/core/pglite-schema.ts + src/schema.sql include the column + FK + index inline for fresh installs. Tests: new test/e2e/source-isolation-pglite.test.ts with 13 regression cases — one per leak surface (search/list_pages/traverse/etc.) plus explicit AuthInfo.sourceId and AuthInfo.allowedSources op-handler threading checks. Full unit suite: 6034 pass / 0 fail. PGLite initSchema time dropped from 2.4s to 850ms after consolidating v55's DO blocks (multiple DO blocks were slow on PGLite; one DO block for the FK install only is fine). Origin: PR #861 + plan-eng-review decisions D2/D3/D4/D9/D10/D13 + F2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(gateway): multimodal embedding for openai-compatible providers Pre-fix, embedMultimodal hardcoded a recipe.id === 'voyage' branch and threw AIConfigError for every other recipe. Multimodal-capable providers fronted by LiteLLM (or any openai-compatible proxy) were unreachable even when the operator had wired up the model. The fix: - src/core/ai/gateway.ts adds embedMultimodalOpenAICompat() that POSTs to the standard /embeddings endpoint with content arrays carrying image_url entries. Routing comes from the existing recipe.implementation switch — Voyage stays on its own /multimodalembeddings path; every other openai-compatible recipe flows through the new helper. - src/core/ai/recipes/litellm-proxy.ts declares supports_multimodal: true so embedMultimodal accepts the recipe. No multimodal_models allow-list: LiteLLM is a passthrough proxy and the user owns model-id selection; provider rejection (400 from upstream) is the right enforcement layer there. Voyage's static allow-list shape stays unchanged (its 12 models share supports_multimodal but only one is multimodal-capable). - D12 runtime dimension validation: the new helper checks the returned vector length against the recipe's declared default_dims (preferred) or the brain's embedding_dimensions config. Mismatch throws AIConfigError with model id + observed + expected so the operator can swap models or rebuild the column. Pre-fix, a wrong-dim response would surface as a cryptic pgvector "vector dimension mismatch" at INSERT time. - Auth resolution routes through the existing defaultResolveAuth helper so optional-auth recipes (LiteLLM proxy with no LITELLM_API_KEY) and required-auth recipes both share one code path. Optional-auth sends "Authorization: Bearer unauthenticated" which servers like Ollama / llama-server ignore but the SDK contract requires. Tests: 11 new cases in test/openai-compat-multimodal.test.ts cover happy-path, multi-input batching, unauthenticated proxy, D12 dim mismatch + default-dim fallback, 401 / 400 / malformed-JSON / non-array error paths, and an explicit Voyage-regression test pinning that the new openai-compat route doesn't accidentally hijack the Voyage path. All 41 multimodal-related tests pass (existing voyage suite + new). typecheck clean. Origin: PR #875 + plan-eng-review D12 (runtime dim validation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(oauth): federated_read read scope (#876) Pre-fix, OAuth clients had a single source-scope axis (source_id, added in v55). A client could either write+read one source OR be a super-reader across all sources (via NULL source_id). There was no middle ground — WeCare-style L3 dept clients that need to write to dept-x but read dept-x + parent canon + shared canon had no expression. #876 adds federated_read TEXT[] as an orthogonal read-scope axis. source_id is the WRITE authority; federated_read is the READ authority. They default to matching values (read scope == write scope, the pre-v0.34 default) when a client is registered without an explicit federated read list. Migrations v56-v60 (six new migrations on top of v55): - v56: ALTER TABLE ... ADD COLUMN federated_read TEXT[] NOT NULL DEFAULT '{}'. - v57 (F5): explicit CASE backfill so source_id IS NULL → '{}' (not an array containing NULL — codex caught this ambiguity during plan review). - v58: post-backfill validation. Fails loud if any row's source_id isn't in its federated_read array, pointing at a logic bug in v57 if fired. - v59: flip the source_id FK from ON DELETE SET NULL to ON DELETE RESTRICT now that federated_read provides the alternative scope-loss path. Pre-flip, deleting a source could silently widen any oauth_client to super-reader; post-flip, source delete is refused if any client references it (operator must revoke/re-scope first). - v60: GIN index on federated_read for array-containment queries. Auth wiring: - GBrainOAuthProvider.verifyAccessToken JOINs c.federated_read and populates AuthInfo.allowedSources. Pre-v56 / pre-v55 brains degrade via the existing isUndefinedColumnError fallback chain. - registerClientManual gains a federatedRead?: string[] parameter (defaults to [sourceId]). - DCR registerClient sets source_id='default' + federated_read=['default'] on the inserted row. - auth.ts CLI adds --federated-read SRC1,SRC2,... flag. The register-client output now prints "Federated reads:" so operators confirm the scope they set. Engines consume the federated array through the SearchOpts.sourceIds / PageFilters.sourceIds field that #861 added (no engine changes here — the plumbing was D9). sourceScopeOpts in operations.ts already prefers the auth.allowedSources array over scalar ctx.sourceId when set. Test seam: - test/book-mirror.test.ts now spawns the CLI with GBRAIN_HOME pointed at a tempdir so the test isn't sensitive to the developer's local ~/.gbrain/config.json. Pre-fix the test could silently inherit a real Postgres connection and hang past the default 5s test timeout. Fresh GBRAIN_HOME → "No brain configured" → exit 1 in <1s. - test/e2e/source-isolation-pglite.test.ts gains one more regression case: AuthInfo.allowedSources = [] (explicit empty) MUST NOT widen scope to "all sources" — the silent-widen footgun precedence ladder. - test/openai-compat-multimodal.test.ts is part of the wave's commits via the migrate.ts changes that bump the schema chain. typecheck-only fix on a captured-auth type was already in #875's tree. 6045 unit tests pass / 0 fail. typecheck clean. PGLite initSchema runs v55-v60 in ~786ms total (within the test-harness budget for tests using the canonical beforeAll engine pattern). Origin: PR #876 + plan-eng-review F5 (CASE backfill). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.34.0.0: MCP fix wave (#870 #909 #864 #861 #875 #876) VERSION + package.json + CHANGELOG bump for the six-PR MCP fix wave. Schema chain extends from v54 → v60; oauth_clients gains source_id + federated_read columns; auth'd MCP clients now stay inside their scope across all read-side ops; PKCE-only DCR works; --bind defaults to loopback; LiteLLM multimodal embedding ships. Contributed by @Hansen1018 (#870), @ding-modding (#909), @DukeDawg (#864), @toilalesondev (#861 + #876), @yoelgal (#875). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update project documentation for v0.34.0.0 Sync README, CLAUDE.md, SECURITY.md, docs/architecture/topologies.md, and docs/mcp/DEPLOY.md to reflect the v0.34.0.0 MCP fix wave: - README: document --bind HOST default (loopback), --source + --federated-read register-client flags, PKCE public-client gate - SECURITY.md: note loopback-by-default for serve --http, update the trust-proxy contract to point at the new default - CLAUDE.md: annotate operations.ts (sourceScopeOpts helper), oauth-provider.ts (verifyAccessToken JOIN + PKCE public clients), serve-http.ts (--bind flag), gateway.ts (openai-compat multimodal + dim validation), mcp/server.ts (MCP_STDIO guard), auth.ts (--source + --federated-read), migrate.ts (v58-v63 chain), engine.ts (sourceIds field). Add 4 new test-file entries for source-isolation-pglite, openai-compat-multimodal, serve-stdio-lifecycle, oauth.test.ts PKCE cases - docs/architecture/topologies.md: source-scoped register-client example, --bind 0.0.0.0 for thin-client host setup - docs/mcp/DEPLOY.md: --bind explanation in the ngrok section, source-scoped client recipe - llms-full.txt: regenerated per the CLAUDE.md-edit chaser rule Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump v0.34.0.0 → v0.34.1.0 Renumbering the MCP fix wave from v0.34.0.0 to v0.34.1.0 so the release slot lands between master's v0.33.2.1 and the next minor. Touches every release-artifact mention: - VERSION: 0.34.0.0 → 0.34.1.0 - package.json: same - CHANGELOG.md header + "To take advantage" block - CLAUDE.md key-files annotations (8 entries that document this wave) - llms-full.txt (regen from CLAUDE.md) - README.md / SECURITY.md / docs/architecture/topologies.md / docs/mcp/DEPLOY.md - Wave code-comment markers ("// v0.34.0 (#NNN):" → "// v0.34.1 (#NNN):") Test files renamed alongside since they were committed with the wave. Commit subjects on the original 6 PR commits + the v0.34.0.0 bump commit (4f533c72→6b47db7e) intentionally NOT rewritten — those are history. `git log` finds the implementation by message subject, not by version tag. 6275 unit tests pass, typecheck clean, migration chain v58-v63 unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
504 lines
19 KiB
TypeScript
504 lines
19 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import { EventEmitter } from 'events';
|
|
import { runServe, type ServeOptions } from '../src/commands/serve';
|
|
import type { BrainEngine } from '../src/core/engine';
|
|
|
|
// These tests cover the stdio lifecycle hooks added to runServe so that the
|
|
// PGLite write lock is released when the parent disconnects. We don't spawn
|
|
// a real Bun child or boot the real MCP SDK; we inject a stub `engine`, a
|
|
// fake stdin Readable (EventEmitter is enough — only on/once/emit are
|
|
// touched), an injected exit() that resolves a promise instead of
|
|
// terminating the process, and (per Codex Layer 2 review feedback) a
|
|
// no-op startMcpServer stub so the real MCP SDK never attaches a 'data'
|
|
// listener to the test runner's actual process.stdin.
|
|
|
|
class StubEngine implements Partial<BrainEngine> {
|
|
// Track whether disconnect was called; the lock-release behavior we care
|
|
// about here is "did the lifecycle path actually invoke disconnect?".
|
|
disconnectCalls = 0;
|
|
disconnect = async (): Promise<void> => {
|
|
this.disconnectCalls += 1;
|
|
};
|
|
}
|
|
|
|
class StubSignals {
|
|
private handlers = new Map<string, Array<(...a: unknown[]) => void>>();
|
|
on(signal: string, handler: (...a: unknown[]) => void): this {
|
|
const list = this.handlers.get(signal) ?? [];
|
|
list.push(handler);
|
|
this.handlers.set(signal, list);
|
|
return this;
|
|
}
|
|
emit(signal: string): void {
|
|
for (const h of this.handlers.get(signal) ?? []) h();
|
|
}
|
|
}
|
|
|
|
// Stub timer pair: `setInterval` returns a numeric handle; `tickAll()`
|
|
// fires every registered fn once, mirroring 1 real-time tick. Lets the
|
|
// test drive the parent-watchdog deterministically without 5s of wall
|
|
// clock and without leaving real timers active across the suite.
|
|
interface TimerStub {
|
|
setInterval: (fn: () => void, ms: number) => unknown;
|
|
clearInterval: (h: unknown) => void;
|
|
tickAll: () => void;
|
|
active: () => number;
|
|
}
|
|
|
|
function makeTimerStub(): TimerStub {
|
|
const fns = new Map<number, () => void>();
|
|
let next = 1;
|
|
return {
|
|
setInterval(fn) {
|
|
const id = next++;
|
|
fns.set(id, fn);
|
|
return id;
|
|
},
|
|
clearInterval(h) {
|
|
if (typeof h === 'number') fns.delete(h);
|
|
},
|
|
tickAll() {
|
|
for (const fn of fns.values()) fn();
|
|
},
|
|
active() {
|
|
return fns.size;
|
|
},
|
|
};
|
|
}
|
|
|
|
interface Harness {
|
|
engine: StubEngine;
|
|
stdin: EventEmitter & { isTTY?: boolean; on: any; once: any };
|
|
signals: StubSignals;
|
|
logs: string[];
|
|
exited: Promise<number>;
|
|
opts: ServeOptions;
|
|
timers: TimerStub;
|
|
setParentPid: (pid: number) => void;
|
|
}
|
|
|
|
function makeHarness(opts: {
|
|
isTTY?: boolean;
|
|
initialParentPid?: number;
|
|
probeWatchdog?: boolean;
|
|
mcpStdio?: boolean;
|
|
} = {}): Harness {
|
|
const engine = new StubEngine();
|
|
const stdin = new EventEmitter() as EventEmitter & { isTTY?: boolean };
|
|
if (opts.isTTY) stdin.isTTY = true;
|
|
const signals = new StubSignals();
|
|
const logs: string[] = [];
|
|
|
|
let resolveExit!: (code: number) => void;
|
|
const exited = new Promise<number>(r => { resolveExit = r; });
|
|
let exitCalled = false;
|
|
|
|
// Mutable parent-pid the test can flip; defaults to a non-1 sentinel
|
|
// so the watchdog *will* install (`initialParentPid !== 1` guard).
|
|
// Tests that want "we were spawned under PID 1" pass `initialParentPid: 1`.
|
|
let parentPid = opts.initialParentPid ?? 12345;
|
|
const timers = makeTimerStub();
|
|
|
|
// probeWatchdog defaults to true so tests run with watchdog installed.
|
|
// Set probeWatchdog: false to simulate stripped-container ps unavailability.
|
|
const probeWatchdogResult = opts.probeWatchdog ?? true;
|
|
|
|
const serveOpts: ServeOptions = {
|
|
stdin: stdin as any,
|
|
signals: signals as any,
|
|
exit: (code?: number) => {
|
|
if (exitCalled) return;
|
|
exitCalled = true;
|
|
resolveExit(code ?? 0);
|
|
},
|
|
log: (msg: string) => { logs.push(msg); },
|
|
// Replace the real MCP SDK boot with a no-op so we never touch the
|
|
// test runner's real process.stdin. The lifecycle hooks under test
|
|
// are installed *before* this is awaited, so all behaviors are still
|
|
// exercised end-to-end.
|
|
startMcpServer: async () => {},
|
|
getParentPid: () => parentPid,
|
|
setInterval: timers.setInterval,
|
|
clearInterval: timers.clearInterval,
|
|
probeWatchdog: () => probeWatchdogResult,
|
|
mcpStdio: opts.mcpStdio,
|
|
};
|
|
|
|
return {
|
|
engine,
|
|
stdin: stdin as any,
|
|
signals,
|
|
logs,
|
|
exited,
|
|
opts: serveOpts,
|
|
timers,
|
|
setParentPid: (pid: number) => { parentPid = pid; },
|
|
};
|
|
}
|
|
|
|
// runServe in tests resolves quickly because the injected startMcpServer
|
|
// is a no-op. The lifecycle hooks were installed synchronously before
|
|
// that no-op was awaited, so they're already wired by the time runServe
|
|
// returns. We start runServe and `await` it (so any setup error surfaces
|
|
// immediately), then drive the test-controlled events.
|
|
async function startInBackground(
|
|
engine: StubEngine,
|
|
args: string[],
|
|
opts: ServeOptions,
|
|
): Promise<void> {
|
|
await runServe(engine as unknown as BrainEngine, args, opts);
|
|
}
|
|
|
|
describe('runServe stdio lifecycle', () => {
|
|
test('stdin end triggers engine.disconnect() and process exit(0)', async () => {
|
|
const h = makeHarness();
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
h.stdin.emit('end');
|
|
const code = await h.exited;
|
|
|
|
expect(code).toBe(0);
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
expect(h.logs.some(l => l.includes('graceful exit (stdin-end)'))).toBe(true);
|
|
});
|
|
|
|
test('SIGTERM triggers graceful exit', async () => {
|
|
const h = makeHarness();
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
h.signals.emit('SIGTERM');
|
|
const code = await h.exited;
|
|
|
|
expect(code).toBe(0);
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
expect(h.logs.some(l => l.includes('graceful exit (SIGTERM)'))).toBe(true);
|
|
});
|
|
|
|
test('SIGINT triggers graceful exit', async () => {
|
|
const h = makeHarness();
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
h.signals.emit('SIGINT');
|
|
const code = await h.exited;
|
|
|
|
expect(code).toBe(0);
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
expect(h.logs.some(l => l.includes('graceful exit (SIGINT)'))).toBe(true);
|
|
});
|
|
|
|
test('SIGHUP triggers graceful exit (terminal disconnect / daemon reload)', async () => {
|
|
// Per Aragorn (#591): real-world hosts (Claude Desktop on macOS,
|
|
// hermes-agent restart) sometimes send SIGHUP instead of closing
|
|
// stdin or sending SIGTERM. The handler converges on the same
|
|
// graceful path as the other signals.
|
|
const h = makeHarness();
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
h.signals.emit('SIGHUP');
|
|
const code = await h.exited;
|
|
|
|
expect(code).toBe(0);
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
expect(h.logs.some(l => l.includes('graceful exit (SIGHUP)'))).toBe(true);
|
|
});
|
|
|
|
test('stdin close (parent SIGKILL leaves pipe destroyed) triggers graceful exit', async () => {
|
|
// 'end' fires on a clean EOF; 'close' fires when the underlying
|
|
// handle is destroyed (e.g. parent SIGKILL'd while pipe still open).
|
|
// We must observe both — observing only 'end' would miss the
|
|
// hard-kill path that #591's reporter hit on macOS.
|
|
const h = makeHarness();
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
h.stdin.emit('close');
|
|
const code = await h.exited;
|
|
|
|
expect(code).toBe(0);
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
expect(h.logs.some(l => l.includes('graceful exit (stdin-close)'))).toBe(true);
|
|
});
|
|
|
|
test('parent watchdog fires shutdown when ppid flips to 1 (orphaned to init)', async () => {
|
|
// Some hosts (launchd, cron, certain MCP gateways) terminate
|
|
// without closing stdin and without sending a signal — the kernel
|
|
// re-parents us. The watchdog polls the live ppid on an interval;
|
|
// when it differs from the initial captured ppid, we detect "parent
|
|
// died" and shut down.
|
|
const h = makeHarness({ initialParentPid: 4242 });
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
// Watchdog should have installed (we passed initialParentPid !== 1
|
|
// and probeWatchdog defaulted to true).
|
|
expect(h.timers.active()).toBe(1);
|
|
|
|
// Simulate parent death: our process gets re-parented to init.
|
|
h.setParentPid(1);
|
|
h.timers.tickAll();
|
|
|
|
const code = await h.exited;
|
|
expect(code).toBe(0);
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
expect(h.logs.some(l => l.includes('graceful exit (parent-died)'))).toBe(true);
|
|
|
|
// beginShutdown clears the watchdog interval as part of cleanup so
|
|
// a duplicate tick can't queue a redundant shutdown.
|
|
expect(h.timers.active()).toBe(0);
|
|
});
|
|
|
|
test('parent watchdog fires shutdown when ppid flips to a SUBREAPER PID > 1 (codex finding #3)', async () => {
|
|
// Reparent-to-PID-1 is the easy case. Real hosts under launchd /
|
|
// systemd / tmux / a parent-shell-with-PR_SET_CHILD_SUBREAPER will
|
|
// re-parent us to that subreaper's PID, NOT to 1. The PR-#676
|
|
// author's original `=== 1` check missed this. The fix is to fire
|
|
// on `current !== initialParentPid` so any reparent triggers the
|
|
// shutdown, regardless of where the kernel re-anchors us.
|
|
const h = makeHarness({ initialParentPid: 8500 });
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
expect(h.timers.active()).toBe(1);
|
|
|
|
// Parent died; kernel re-parented to a launchd subreaper (PID 47).
|
|
h.setParentPid(47);
|
|
h.timers.tickAll();
|
|
|
|
const code = await h.exited;
|
|
expect(code).toBe(0);
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
expect(h.logs.some(l => l.includes('graceful exit (parent-died)'))).toBe(true);
|
|
expect(h.timers.active()).toBe(0);
|
|
});
|
|
|
|
test('parent watchdog NOT installed when initial ppid is already 1 (legitimate init child)', async () => {
|
|
// Spawned directly under PID 1 (e.g. systemd unit, Docker entrypoint):
|
|
// ppid=1 is the documented steady state, not "parent died". We must
|
|
// NOT install the watchdog or we'd shut down immediately.
|
|
const h = makeHarness({ initialParentPid: 1 });
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
expect(h.timers.active()).toBe(0);
|
|
|
|
// Sanity: the other lifecycle paths still work.
|
|
h.signals.emit('SIGTERM');
|
|
await h.exited;
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
});
|
|
|
|
test('parent watchdog NOT installed when ps probe fails (codex finding #4 / D2-revisited)', async () => {
|
|
// Stripped containers / busybox-without-procps environments lack ps.
|
|
// The original PR's per-tick fallback would silently return cached
|
|
// process.ppid, never detect a change, and never fire the shutdown
|
|
// — while still claiming to be active.
|
|
//
|
|
// The fix: a one-shot startup probe. When it returns false, we skip
|
|
// installing the watchdog interval AND emit a loud stderr line so
|
|
// the operator sees the degraded mode at startup.
|
|
const h = makeHarness({ initialParentPid: 4242, probeWatchdog: false });
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
// Watchdog NOT installed — message matches behavior.
|
|
expect(h.timers.active()).toBe(0);
|
|
expect(h.logs.some(l => l.includes('[gbrain serve] watchdog disabled: ps unavailable'))).toBe(true);
|
|
|
|
// Sanity: the other lifecycle paths still work — the shutdown still
|
|
// funnels through stdin EOF / signals, just not via the watchdog.
|
|
h.signals.emit('SIGTERM');
|
|
await h.exited;
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
});
|
|
|
|
test('parent watchdog tick with ppid still alive does NOT fire shutdown', async () => {
|
|
// The watchdog must only fire on the *transition* away from the
|
|
// initial ppid; a healthy tick (ppid still equal to the original)
|
|
// is a no-op.
|
|
const h = makeHarness({ initialParentPid: 4242 });
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
expect(h.timers.active()).toBe(1);
|
|
// Tick with ppid unchanged.
|
|
h.timers.tickAll();
|
|
h.timers.tickAll();
|
|
h.timers.tickAll();
|
|
expect(h.engine.disconnectCalls).toBe(0);
|
|
|
|
// ... and signal-driven shutdown still works after several quiet ticks.
|
|
h.signals.emit('SIGTERM');
|
|
await h.exited;
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
});
|
|
|
|
test('shutdown is idempotent — multiple signals only disconnect once', async () => {
|
|
const h = makeHarness();
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
h.signals.emit('SIGTERM');
|
|
h.signals.emit('SIGTERM');
|
|
h.signals.emit('SIGINT');
|
|
h.stdin.emit('end');
|
|
|
|
await h.exited;
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
});
|
|
|
|
test('TTY stdin does NOT install end watcher (interactive use unaffected)', async () => {
|
|
const h = makeHarness({ isTTY: true });
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
// Emit 'end' on TTY stdin — no listener should be wired so this is a
|
|
// no-op. The test passes by simply not exiting; we give the runtime a
|
|
// beat to confirm nothing fires. Signals must still work.
|
|
h.stdin.emit('end');
|
|
await new Promise(r => setTimeout(r, 10));
|
|
expect(h.engine.disconnectCalls).toBe(0);
|
|
|
|
// Sanity: signals still wired regardless of TTY-ness.
|
|
h.signals.emit('SIGTERM');
|
|
await h.exited;
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
});
|
|
|
|
test('--stdio-idle-timeout 0 disarms the idle hook (sanity)', async () => {
|
|
const h = makeHarness();
|
|
await startInBackground(h.engine, ['--stdio-idle-timeout', '0'], h.opts);
|
|
|
|
// 0 is the documented opt-out. No idle hook should be armed; drive a
|
|
// different exit path to confirm flow still works.
|
|
h.signals.emit('SIGTERM');
|
|
await h.exited;
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
expect(h.logs.every(l => !l.includes('idle timeout'))).toBe(true);
|
|
});
|
|
|
|
test('--stdio-idle-timeout > 0 logs the configured value', async () => {
|
|
const h = makeHarness();
|
|
await startInBackground(h.engine, ['--stdio-idle-timeout', '60'], h.opts);
|
|
|
|
expect(h.logs.some(l => l.includes('stdio idle timeout = 60s'))).toBe(true);
|
|
|
|
h.signals.emit('SIGTERM');
|
|
await h.exited;
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
});
|
|
|
|
test('idle timer is reset on every stdin data chunk', async () => {
|
|
const h = makeHarness();
|
|
// Use a very short timeout so we can observe the firing/resetting
|
|
// without slowing the suite. 50ms is enough to be measurable but
|
|
// short enough that the suite finishes promptly.
|
|
await startInBackground(
|
|
h.engine,
|
|
['--stdio-idle-timeout', '1'], // 1 second; we reset it before it fires
|
|
h.opts,
|
|
);
|
|
|
|
// Pulse 'data' a few times to keep the timer reset.
|
|
for (let i = 0; i < 3; i++) {
|
|
h.stdin.emit('data', Buffer.from('{"jsonrpc":"2.0"}'));
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
expect(h.engine.disconnectCalls).toBe(0);
|
|
|
|
// Now stop pulsing and wait for the timer to actually fire end-to-end
|
|
// (it ought to elapse within ~1s of the last reset). Awaiting
|
|
// h.exited rather than a wall-clock race makes this deterministic.
|
|
await h.exited;
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
expect(h.logs.some(l => l.includes('stdio-idle-timeout (1s)'))).toBe(true);
|
|
}, 5000);
|
|
|
|
test.each([
|
|
['abc', /--stdio-idle-timeout/],
|
|
['30junk', /--stdio-idle-timeout/],
|
|
['-1', /--stdio-idle-timeout/],
|
|
['1.5', /--stdio-idle-timeout/],
|
|
['', /--stdio-idle-timeout/],
|
|
])('--stdio-idle-timeout rejects invalid value %p (typo is a CLI error)', async (bad, msgRe) => {
|
|
// Per Codex Layer 2 review P1: silent fallback on typo turns the
|
|
// opt-in safety net into a no-op. Strict parsing throws so the
|
|
// operator sees the mistake immediately.
|
|
const h = makeHarness();
|
|
expect(
|
|
runServe(h.engine as unknown as BrainEngine, ['--stdio-idle-timeout', bad], h.opts),
|
|
).rejects.toThrow(msgRe);
|
|
});
|
|
|
|
test('--stdio-idle-timeout with no following value also throws', async () => {
|
|
const h = makeHarness();
|
|
// Flag at end of args — no value to consume.
|
|
expect(
|
|
runServe(h.engine as unknown as BrainEngine, ['--stdio-idle-timeout'], h.opts),
|
|
).rejects.toThrow(/missing value/);
|
|
});
|
|
|
|
test('engine.disconnect throwing still results in exit(0) and logged error', async () => {
|
|
const h = makeHarness();
|
|
h.engine.disconnect = async () => {
|
|
throw new Error('synthetic disconnect failure');
|
|
};
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
h.signals.emit('SIGTERM');
|
|
const code = await h.exited;
|
|
expect(code).toBe(0);
|
|
expect(h.logs.some(l => l.includes('cleanup error: synthetic disconnect failure'))).toBe(true);
|
|
});
|
|
|
|
// v0.34.1 (#870): OpenClaw gateway / bundle-mcp wrappers pipe the
|
|
// JSON-RPC handshake on stdin then close their stdin half. Without
|
|
// MCP_STDIO=1 the server treats that as a permanent disconnect and
|
|
// exits before handling tools/call. The guard skips the stdin 'end' /
|
|
// 'close' hooks when MCP_STDIO=1; signals and parent watchdog still
|
|
// cover legitimate shutdown.
|
|
describe('MCP_STDIO=1 piped-stdin guard (#870)', () => {
|
|
test('stdin end with mcpStdio=true does NOT trigger shutdown', async () => {
|
|
const h = makeHarness({ mcpStdio: true });
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
// Without the guard this would shutdown; with the guard it must not.
|
|
h.stdin.emit('end');
|
|
|
|
// Give the event loop a microtask turn to catch any erroneous shutdown
|
|
// path. We assert NO exit was registered.
|
|
await new Promise<void>((r) => setTimeout(r, 10));
|
|
expect(h.engine.disconnectCalls).toBe(0);
|
|
|
|
// Then trigger SIGTERM to drive the test to completion; signal handlers
|
|
// remain active even with mcpStdio=true (codex would catch if they didn't).
|
|
h.signals.emit('SIGTERM');
|
|
const code = await h.exited;
|
|
expect(code).toBe(0);
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
expect(h.logs.some(l => l.includes('graceful exit (SIGTERM)'))).toBe(true);
|
|
});
|
|
|
|
test('stdin close with mcpStdio=true does NOT trigger shutdown', async () => {
|
|
const h = makeHarness({ mcpStdio: true });
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
h.stdin.emit('close');
|
|
|
|
await new Promise<void>((r) => setTimeout(r, 10));
|
|
expect(h.engine.disconnectCalls).toBe(0);
|
|
|
|
h.signals.emit('SIGINT');
|
|
const code = await h.exited;
|
|
expect(code).toBe(0);
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
});
|
|
|
|
test('mcpStdio=false (default) preserves stdin EOF shutdown', async () => {
|
|
// Regression guard: the guard must not over-trigger. With the env
|
|
// unset, stdin EOF must still drive shutdown so existing CLI usage
|
|
// (gbrain serve under launchd, claude-desktop's stdio MCP) is
|
|
// unchanged.
|
|
const h = makeHarness({ mcpStdio: false });
|
|
await startInBackground(h.engine, [], h.opts);
|
|
|
|
h.stdin.emit('end');
|
|
const code = await h.exited;
|
|
expect(code).toBe(0);
|
|
expect(h.engine.disconnectCalls).toBe(1);
|
|
expect(h.logs.some(l => l.includes('graceful exit (stdin-end)'))).toBe(true);
|
|
});
|
|
});
|
|
});
|