Files
gbrain/src/core/context/resolve-ipc.ts
T
Garry TanandClaude Fable 5 c2cc8b0207 v0.45.1.0 feat(context): hook-lane feedback loop + cross-turn dedupe for the harness hooks (#4025)
* refactor(context): extract pure gateVolunteeredPointers (no behavior change)

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

* feat(context): volunteer-shaped resolve IPC — channel/cwd/volunteer fields, handler-result widening, server-side gate + validated source + delivery-point logging; listener on both engines

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

* feat(cli): gbrain volunteer-hook — one-shot harness hook entry (IPC-first; Postgres-only fallback; startup-hook-exempt)

T0 evidence: a UserPromptSubmit hook's additionalContext is recorded in the
session transcript as a structured hook_additional_context attachment and
transcript_path is stable across turns — cross-turn dedupe reads ONLY those
attachments (never raw tail substrings). Live smoke: seeded brain + running
serve → pointer injected via IPC at 0.43s wall, turn 2 suppressed, one event
banked channel=claude-code.

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

* feat(recipes): claude-code-reflex + codex-reflex (experimental) hook adapters

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

* feat(doctor): volunteer_channels per-adapter visibility

Engine-aware sibling of retrieval_reflex_health: groups
context_volunteer_events by channel (7d) so operators can see which push
adapters fire; message separates installed-but-unregistered from
registered-but-quiet; pre-v117 tolerant; info-only status.

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

* feat(context): hook-lane feedback loop + cross-turn dedupe — converge cathedral-3 onto the #3975 harness hooks

The #3975 hook lane volunteered pages invisibly (nothing logged to
context_volunteer_events) and re-volunteered the same page every turn an
entity was mentioned (no priorContextText). This convergence ports the
cathedral-3 branch's two gap-fills into the blessed lane and retires its
parallel surface:

- transcripts: parseTranscript extracts injectedContextBlocks (structured
  hook_additional_context attachments — the T0-verified recorded shape of
  our own injections; never raw-turn substring matching)
- hook user-prompt: feeds those blocks as priorContextText (volunteer once
  per session, not once per mention) + sends a feedback-loop channel
  (--harness <claude-code|codex>, default claude-code); hook joins
  STARTUP_HOOK_SKIP_COMMANDS (per-prompt check-update spawn class, grep-pinned)
- turn-context: result exposes post-trim volunteered survivors
- resolve-ipc: additive TurnContextRequest.channel + onTurnContextDelivered
  seam (fires post-write only — abandoned blocks are never counted)
- serve: delivery callback logs volunteered pages + pointers per channel
- doctor: volunteer_channels check (per-channel 7d visibility; separates
  installed-but-unregistered from registered-but-quiet; pre-v117 tolerant)

Retired (never released): gbrain volunteer-hook, hook-adapters.ts,
claude-code-reflex + codex-reflex recipes, the volunteer-shaped resolve wire
extension. Kept: gateVolunteeredPointers extraction, VolunteerChannel
claude-code/codex values, the real hook-transcript fixture.

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

* docs: regenerate llms bundles for the hook-lane doc updates

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

* test: coverage for the hook-lane delivery wiring

Extract logTurnContextDeliveryFireAndForget into volunteer-events.ts so the
shipped serve callback is unit-testable (channel fallback, sessionId clamp,
pointer+page rows in ONE synchronous registration — the double dynamic import
could register the pending write too late to survive process exit). Direct
units for isVolunteerChannel, explicit-channel logDeliveredReflexPointers,
and the pure gate (idempotency pin; stale parity claim in its doc corrected).

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

* fix: pre-landing review fixes — 7-reviewer batch

Multi-confirmed: volunteer_channels now runs on the LOCAL doctor path too
(buildChecks; docs pointed there); transient DB errors no longer misreported
as pre-v117; priorContextText deduped + 32KB-capped hook-side. Red team:
delivery counts reconciled against the hook heartbeat (CAUTION when mostly
degraded — write-accept isn't injection); IPC clamp drops advisory
priorContextText BEFORE any window turn; foreign hook_additional_context
blocks (no gbrain marker) excluded from dedupe input; engine-aware quiet
guidance (Postgres brains aren't told to chase hook registration); 2MB dedupe
horizon documented. Security: wire channel claims restricted to harness
channels. DRY: VOLUNTEER_CHANNELS single source, shared reflexPointerRationale,
SESSION_ID_MAX_LEN shared with the op clamp; dead channel param dropped from
logDeliveredReflexPointers. Tests: budget-trim volunteered invariant under
real trim pressure, poll/ordering-based delivery-seam tests (no fixed sleeps),
doctor branch + engine-parity-row tests, wiring source pins, cap pins;
fixture pruned to load-bearing lines (environment inventory removed).

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

* fix: adversarial-review fixes — cross-model P1 + Claude adversarial batch

P1 (codex structured review + Claude adversarial, cross-model): the remote
run_doctor path now threads the caller's source scope into
volunteer_channels — a source-bound token can no longer see other sources'
activity counts/timestamps (must-never-violate source-isolation rule);
local trusted doctor stays brain-wide. Claude adversarial: IPC connection
handler gains a one-request re-entrancy guard (trailing bytes mid-await
double-processed the same line → duplicate handler work + duplicate
delivery logging); partial client-side trims now heartbeat as reason
'trimmed' (delivery-count drift visibility); heartbeat reconciliation uses
the same 7-day window as the counts + a 5-sample floor; one oversized
dedupe block no longer evicts all smaller ones (continue, not break);
quiet-channel guidance no longer claims old serves attribute hook traffic
to reflex (they log nothing); 'transient' claim softened to evidence;
marker-filter + unknown-channel-default comments state their honest limits.
Pins: scoped-query SQL/params, re-entrancy single-fire, updated wiring grep.

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

* chore: bump version and changelog (v0.45.1.0)

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

* chore: restamp bootstrap runbook + regenerate vendored template repo for v0.45.1.0

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

* docs: update project documentation for v0.45.1.0

Post-ship /document-release pass: fold the post-doc review fixes into the
reference docs (KEY_FILES: source-scoped remote volunteer_channels + heartbeat
reconciliation, 32KB-capped dedupe input, marker filter, IPC one-request
guard + clamp order), fix BRAINBENCH's now-stale memoryless-hook rationale,
and mark the doctor-check half of the push-context TODO shipped.

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

* docs: cross-model doc-review fixes for v0.45.1.0

Codex doc review against the shipped diff: push-context channel table gains
the harness-hook row (three-channels claim was stale), the guide states the
PGLite-socket topology limit and the bounded dedupe horizon, delivery-point
claims are hedged to match the code (write-accept isn't proof of injection;
the doctor reconciles against the hook heartbeat, which can't inspect
registration), BRAINBENCH's future-tense hooks-PR sentence updated now the
hook has landed, and the adapter-flip + resolver-channel TODOS reflect the
current lane count. llms-full.txt regenerated.

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

* docs(hook): document --harness in the user-prompt usage text

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 22:45:44 -07:00

525 lines
22 KiB
TypeScript

/**
* Retrieval Reflex — resolve IPC (issue #1981, D9=C) + turn-context IPC v2
* (agent-bootstrap plan: ENG-3, A9, S3#6, G11, CX2-10).
*
* PGLite is single-connection: `gbrain serve` holds the one connection for its
* lifetime, so the context engine cannot open its own and must NOT shell out to
* a subprocess (that would force-steal the lock past the 5-min staleness window
* and crash the brain — see plan D9 rejected option). Instead, `serve`
* optionally listens on a local unix-domain socket and answers NARROW requests
* using the connection it already owns. Both ends are gbrain code; raw SQL
* never crosses the wire (closes the trust hole).
*
* Protocol: newline-delimited JSON. One request line, one response line.
* Requests form a discriminated union on `kind`; ABSENT kind means 'resolve'
* so every v1 client keeps working against a v2 server unchanged [ENG-3]:
*
* resolve (v1, secret-free):
* req: { kind?: 'resolve', candidates, priorContextText?, maxPointers?, sourceId? }
* resp: { ok: true, block: PointerBlock | null } | { ok: false, error }
*
* turn_context (v2, secret-gated [S3#6], source-bound [CX2-10]):
* req: { kind: 'turn_context', protocol: 2, secret, window, priorContextText?,
* sessionId?, sourceId?, maxBytes? }
* resp: { ok, protocol: 2, block?, degradedReason?, error? }
*
* The protocol:2 echo on every turn_context response is the stale-serve
* detector [A9]: a v1 server answers a turn_context request as a resolve
* request (`{ok:true, block:null}`, no echo) and the client degrades to a
* typed { degraded: 'stale_serve' } instead of trusting the empty block.
*
* Local-only (unix socket in a 0700 dir on the brain's data dir, socket mode
* 0600 set before readiness is announced) — no network surface.
*/
import net from 'node:net';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import {
existsSync,
unlinkSync,
statSync,
chmodSync,
mkdirSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { join, dirname } from 'node:path';
import type { EntityCandidate } from './entity-salience.ts';
import type { WindowTurn } from './entity-salience.ts';
import type { PointerBlock } from './retrieval-reflex.ts';
import type { TurnContextResult } from './turn-context.ts';
const SOCK_NAME = '.gbrain-resolve.sock';
const SECRET_NAME = '.gbrain-ipc-secret';
/** Per-kind budgets [G11]: resolve keeps its legacy 250ms client timeout. */
const CLIENT_TIMEOUT_MS = 250;
/** turn_context does real assembly work — wider client budget (still <1s). */
export const TURN_CONTEXT_CLIENT_TIMEOUT_MS = 600;
/** Server-side self-budget for turn_context assembly (< client timeout). */
export const TURN_CONTEXT_SERVER_BUDGET_MS = 400;
const MAX_MSG_BYTES = 256 * 1024;
/** Marker the client returns when no server is reachable (vs. a real null result). */
export const IPC_UNAVAILABLE = Symbol('ipc-unavailable');
// ── Request / response types (discriminated union, named responses) ───────
export interface ResolveRequest {
/** Absent kind means 'resolve' — v1 clients never send it (back-compat). */
kind?: 'resolve';
candidates: EntityCandidate[];
priorContextText?: string;
maxPointers?: number;
/**
* Optional source claim. On a server started with opts.boundSourceId, any
* OTHER value is rejected with 'source_mismatch' [CX2-10] — same binding as
* turn_context. Unbound (legacy positional) servers pass it through.
*/
sourceId?: string;
/** v0.43 (#2095, codex D7): suppression mode — 'slug-only' under windowing. */
suppression?: 'slug-and-title' | 'slug-only';
}
export interface TurnContextRequest {
kind: 'turn_context';
/** Protocol version claim; the server echoes it so clients can detect a stale serve [A9]. */
protocol: 2;
/** Shared secret from `<dataDir>/.gbrain-ipc-secret` [S3#6]. resolve stays secret-free. */
secret: string;
/** Recent conversation turns, oldest → newest (trimmed oldest-first to fit the message cap [G11]). */
window: WindowTurn[];
priorContextText?: string;
sessionId?: string;
/** Optional source claim — the server REJECTS any value other than its bound source [CX2-10]. */
sourceId?: string;
maxBytes?: number;
/**
* Event-attribution channel for the delivery-point feedback loop (harness
* hook adapters): the server logs the delivered block's volunteered pages /
* pointers to context_volunteer_events under this channel so
* `volunteer-context --stats` and the volunteer_channels doctor check see
* per-harness firing. Validated server-side against the known channel set;
* absent/unknown → 'claude-code' (the only harness bootstrap registers
* hooks for today). Additive: old servers ignore it (no logging — the
* pre-feedback-loop status quo).
*/
channel?: string;
}
export type IpcRequest = ResolveRequest | TurnContextRequest;
export interface ResolveResponse {
ok: boolean;
block?: PointerBlock | null;
error?: string;
}
export interface TurnContextResponse {
ok: boolean;
/** Always 2 on a v2 server. A response WITHOUT this echo is a stale (v1) serve [A9]. */
protocol: 2;
block?: TurnContextResult | null;
degradedReason?: string;
error?: string;
}
export type ResolveHandler = (req: ResolveRequest) => Promise<PointerBlock | null>;
export type TurnContextHandler = (req: TurnContextRequest) => Promise<TurnContextResult | null>;
/** Handler MAP replacing the single closure [ENG-3]. */
export interface IpcHandlers {
resolve: ResolveHandler;
turn_context?: TurnContextHandler;
}
export interface IpcServerOpts {
/**
* v0.43 (#2095, red-team): fired ONLY after the resolve response was
* successfully written to the client — the accept-side seam for
* reflex-channel feedback logging. A block the client never received
* (timeout, dead socket) was never injected into a prompt and must not
* count as "volunteered".
*/
onDelivered?: (block: PointerBlock, req: ResolveRequest) => void;
/**
* turn_context sibling of onDelivered — fired ONLY after an ok
* turn_context response with a non-empty block was successfully written to
* the client. This is the #2095 feedback-loop seam for the hook lane: the
* callback logs the block's post-trim volunteered pages + pointers to
* context_volunteer_events under req.channel. Same red-team rule as
* onDelivered: a block the client's budget abandoned was never injected
* and must not be counted.
*/
onTurnContextDelivered?: (result: TurnContextResult, req: TurnContextRequest) => void;
/**
* The server's registered source [CX2-10]. turn_context requests naming a
* DIFFERENT sourceId are rejected with 'source_mismatch'; the handler always
* assembles against the bound source regardless.
*/
boundSourceId?: string;
/**
* Shared secret value for turn_context [S3#6] (from ensureIpcSecret).
* When a turn_context handler is registered without a secret, every
* turn_context request is rejected 'unauthorized' (fail closed).
*/
secret?: string;
}
/** Canonical socket path for a PGLite data dir. */
export function resolveSocketPath(dataDir: string): string {
return join(dataDir, SOCK_NAME);
}
// ── Shared secret [S3#6] ──────────────────────────────────────────────────
/** Canonical shared-secret file path for a PGLite data dir. */
export function ipcSecretPath(dataDir: string): string {
return join(dataDir, SECRET_NAME);
}
/**
* Server-side: read the shared secret, creating a fresh 32-byte random hex
* secret at `<dataDir>/.gbrain-ipc-secret` (mode 0600) if absent. Throws only
* when the file can neither be read nor created (callers treat that as
* "turn_context disabled", never as "skip auth").
*/
export function ensureIpcSecret(dataDir: string): string {
const p = ipcSecretPath(dataDir);
try {
const existing = readFileSync(p, 'utf8').trim();
if (existing) {
try { chmodSync(p, 0o600); } catch { /* best effort */ }
return existing;
}
} catch { /* absent or unreadable — create below */ }
const secret = randomBytes(32).toString('hex');
mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
writeFileSync(p, secret + '\n', { mode: 0o600 });
try { chmodSync(p, 0o600); } catch { /* best effort */ }
return secret;
}
/** Client-side: read the shared secret; null when absent (no server has created it). */
export function readIpcSecret(dataDir: string): string | null {
try {
const s = readFileSync(ipcSecretPath(dataDir), 'utf8').trim();
return s || null;
} catch {
return null;
}
}
/** Constant-time secret comparison (length mismatch short-circuits — leaks length only). */
function secretMatches(candidate: unknown, expected: string): boolean {
if (typeof candidate !== 'string' || !candidate || !expected) return false;
const a = Buffer.from(candidate, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length) return false;
try {
return timingSafeEqual(a, b);
} catch {
return false;
}
}
// ── Clients ───────────────────────────────────────────────────────────────
/**
* v1 client: ship candidates to a running serve, get pointers back. Returns
* IPC_UNAVAILABLE when no server is listening (caller falls through the ladder);
* a real PointerBlock | null otherwise. Never throws — fail-soft to UNAVAILABLE.
*/
export async function resolveViaIpc(
socketPath: string,
req: ResolveRequest,
): Promise<PointerBlock | null | typeof IPC_UNAVAILABLE> {
const resp = await roundTrip(socketPath, JSON.stringify(req), CLIENT_TIMEOUT_MS);
if (resp === IPC_UNAVAILABLE) return IPC_UNAVAILABLE;
if (resp && (resp as ResolveResponse).ok) return (resp as ResolveResponse).block ?? null;
return IPC_UNAVAILABLE;
}
/** Client-facing turn_context request shape (kind/protocol are filled in by the helper). */
export type TurnContextClientRequest = Omit<TurnContextRequest, 'kind' | 'protocol'>;
/**
* Typed degraded marker for a v1 server answering a v2 request [A9] — the
* response parsed but carried no protocol echo, so its (empty) block must
* not be trusted as "nothing relevant".
*/
export interface TurnContextStaleServe {
degraded: 'stale_serve';
}
export type TurnContextIpcResult =
| TurnContextResponse
| TurnContextStaleServe
| typeof IPC_UNAVAILABLE;
/**
* v2 client: request an assembled turn-context block from a running serve.
* Mirrors resolveViaIpc's fail-soft style — socket/timeout/parse trouble is
* IPC_UNAVAILABLE, a protocol-less response is { degraded: 'stale_serve' },
* everything else is the server's typed TurnContextResponse (including
* ok:false rejections like 'unauthorized' / 'source_mismatch', which callers
* may want to surface). Never throws.
*
* [G11] The request is clamped below the 256KB message cap before send by
* trimming window turns oldest-first.
*/
export async function requestTurnContext(
socketPath: string,
req: TurnContextClientRequest,
opts: { timeoutMs?: number } = {},
): Promise<TurnContextIpcResult> {
const full: TurnContextRequest = {
kind: 'turn_context',
protocol: 2,
...req,
window: Array.isArray(req.window) ? [...req.window] : [],
};
let line = JSON.stringify(full);
// Trim to the message cap [G11] in priority order: the ADVISORY dedupe
// payload (priorContextText) is dropped BEFORE any essential window turn —
// evicting the window first would silently hollow out candidate extraction
// (empty blocks with ok:true) to preserve a hint. Then window turns,
// oldest-first.
if (Buffer.byteLength(line, 'utf8') + 1 > MAX_MSG_BYTES && full.priorContextText) {
delete full.priorContextText;
line = JSON.stringify(full);
}
while (Buffer.byteLength(line, 'utf8') + 1 > MAX_MSG_BYTES && full.window.length > 0) {
full.window.shift();
line = JSON.stringify(full);
}
if (Buffer.byteLength(line, 'utf8') + 1 > MAX_MSG_BYTES) return IPC_UNAVAILABLE;
const resp = await roundTrip(socketPath, line, opts.timeoutMs ?? TURN_CONTEXT_CLIENT_TIMEOUT_MS);
if (resp === IPC_UNAVAILABLE) return IPC_UNAVAILABLE;
if (!resp || typeof resp !== 'object') return IPC_UNAVAILABLE;
// Stale-serve detection [A9]: no protocol echo → a v1 server handled this
// as a resolve request; its block is meaningless for turn_context.
if ((resp as { protocol?: unknown }).protocol !== 2) return { degraded: 'stale_serve' };
return resp as TurnContextResponse;
}
/** One request line out, one response line back. Fail-soft to IPC_UNAVAILABLE. */
function roundTrip(
socketPath: string,
requestLine: string,
timeoutMs: number,
): Promise<unknown | typeof IPC_UNAVAILABLE> {
if (!existsSync(socketPath)) return Promise.resolve(IPC_UNAVAILABLE);
return new Promise((resolve) => {
let settled = false;
let buf = '';
const finish = (v: unknown | typeof IPC_UNAVAILABLE) => {
if (settled) return;
settled = true;
try { sock.destroy(); } catch { /* noop */ }
resolve(v);
};
const sock = net.createConnection(socketPath);
sock.setTimeout(timeoutMs);
sock.on('connect', () => {
sock.write(requestLine + '\n');
});
sock.on('data', (chunk) => {
buf += chunk.toString('utf8');
if (buf.length > MAX_MSG_BYTES) return finish(IPC_UNAVAILABLE);
const nl = buf.indexOf('\n');
if (nl < 0) return;
try {
return finish(JSON.parse(buf.slice(0, nl)));
} catch {
return finish(IPC_UNAVAILABLE);
}
});
// Any error (ENOENT, ECONNREFUSED, stale socket), timeout, or close before
// a response → treat as unavailable, fall through the ladder.
sock.on('timeout', () => finish(IPC_UNAVAILABLE));
sock.on('error', () => finish(IPC_UNAVAILABLE));
sock.on('close', () => finish(IPC_UNAVAILABLE));
});
}
// ── Server ────────────────────────────────────────────────────────────────
/**
* Server: start an IPC listener on `socketPath`. Cleans up a stale socket
* left by a dead owner first, hardens the parent dir to 0700, and chmods the
* socket 0600 BEFORE announcing readiness [S3#6]. Returns the net.Server
* (caller closes on shutdown). Errors are swallowed (best-effort feature) —
* returns null if the socket can't be bound.
*
* Two call shapes [ENG-3]:
* - legacy positional: (socketPath, resolveHandler, onDelivered?) — v1
* callers unchanged.
* - handler map: (socketPath, { resolve, turn_context? }, opts?) — v2.
*/
export async function startResolveIpcServer(
socketPath: string,
handler: ResolveHandler,
onDelivered?: (block: PointerBlock, req: ResolveRequest) => void,
): Promise<net.Server | null>;
export async function startResolveIpcServer(
socketPath: string,
handlers: IpcHandlers,
opts?: IpcServerOpts,
): Promise<net.Server | null>;
export async function startResolveIpcServer(
socketPath: string,
handlerOrHandlers: ResolveHandler | IpcHandlers,
onDeliveredOrOpts?: ((block: PointerBlock, req: ResolveRequest) => void) | IpcServerOpts,
): Promise<net.Server | null> {
const handlers: IpcHandlers =
typeof handlerOrHandlers === 'function' ? { resolve: handlerOrHandlers } : handlerOrHandlers;
const opts: IpcServerOpts =
typeof onDeliveredOrOpts === 'function'
? { onDelivered: onDeliveredOrOpts }
: onDeliveredOrOpts ?? {};
// [S3#6] Parent dir 0700 (create if missing, tighten if present) so an
// unrelated local user can't even see the socket / secret names.
try {
const dir = dirname(socketPath);
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(dir, 0o700);
} catch { /* best effort */ }
// Remove a stale socket file if present (a previous serve that didn't clean up).
cleanupStaleSocket(socketPath);
return new Promise((resolve) => {
const server = net.createServer((conn) => {
let buf = '';
// One request per connection: once a line is being handled, later data
// events are ignored. Without this, bytes arriving after the newline
// while the async handler is mid-await would re-find the SAME first
// line and process it concurrently — duplicate handler work, duplicate
// response writes, and duplicated delivery-point event logging.
let handled = false;
conn.setEncoding('utf8');
conn.on('data', async (chunk: string) => {
if (handled) return;
buf += chunk;
if (buf.length > MAX_MSG_BYTES) { conn.destroy(); return; }
const nl = buf.indexOf('\n');
if (nl < 0) return;
handled = true;
const line = buf.slice(0, nl);
let resp: string;
let delivered: { block: PointerBlock; req: ResolveRequest } | null = null;
let deliveredTurnContext: { result: TurnContextResult; req: TurnContextRequest } | null = null;
try {
const parsed = JSON.parse(line) as IpcRequest;
const kind = (parsed as { kind?: unknown }).kind ?? 'resolve';
if (kind === 'resolve') {
const req = parsed as ResolveRequest;
// [CX2-10] Same source binding as turn_context: a bound server
// serves ITS registered source only — a resolve request naming a
// different source is rejected, never re-routed. Unbound servers
// (legacy positional callers) keep the v1 pass-through behavior.
if (req.sourceId && opts.boundSourceId && req.sourceId !== opts.boundSourceId) {
resp = JSON.stringify({ ok: false, error: 'source_mismatch' } satisfies ResolveResponse);
} else {
const block = await handlers.resolve(req);
const out: ResolveResponse = { ok: true, block };
resp = JSON.stringify(out);
if (block) delivered = { block, req };
}
} else if (kind === 'turn_context') {
const req = parsed as TurnContextRequest;
const tcResp = await handleTurnContext(req, handlers, opts);
resp = JSON.stringify(tcResp);
// Feedback-loop seam: only an ok response carrying a non-empty
// block counts as a candidate delivery (rejections, degraded-null
// and empty blocks injected nothing).
if (tcResp.ok && tcResp.block && tcResp.block.text) {
deliveredTurnContext = { result: tcResp.block, req };
}
} else {
resp = JSON.stringify({ ok: false, error: `unknown_kind:${String(kind)}` });
}
} catch (e) {
resp = JSON.stringify({ ok: false, error: (e as Error).message });
}
try {
conn.write(resp + '\n');
// Write accepted — the client (250ms budget) may still have hung
// up, but this is the closest observable delivery point.
if (delivered && opts.onDelivered) {
try { opts.onDelivered(delivered.block, delivered.req); } catch { /* telemetry only */ }
}
if (deliveredTurnContext && opts.onTurnContextDelivered) {
try { opts.onTurnContextDelivered(deliveredTurnContext.result, deliveredTurnContext.req); } catch { /* telemetry only */ }
}
} catch { /* client gone — do NOT log undelivered pointers */ }
conn.end();
});
conn.on('error', () => { try { conn.destroy(); } catch { /* noop */ } });
});
server.on('error', () => resolve(null));
server.listen(socketPath, () => {
// Mode set BEFORE readiness is announced (the resolve() below) [S3#6].
try { chmodSync(socketPath, 0o600); } catch { /* best effort */ }
resolve(server);
});
});
}
/** turn_context server path: auth [S3#6] → source binding [CX2-10] → budgeted assembly [G11]. */
async function handleTurnContext(
req: TurnContextRequest,
handlers: IpcHandlers,
opts: IpcServerOpts,
): Promise<TurnContextResponse> {
if (!handlers.turn_context) {
return { ok: false, protocol: 2, error: 'unsupported_kind' };
}
if (req.protocol !== 2) {
return { ok: false, protocol: 2, error: 'unsupported_protocol' };
}
// Fail closed: no configured secret means NO turn_context service, not open service.
if (!opts.secret || !secretMatches(req.secret, opts.secret)) {
return { ok: false, protocol: 2, error: 'unauthorized' };
}
// [CX2-10] The server serves ITS registered source only. A request naming a
// different source is an authorization error, not a routing hint.
if (req.sourceId && opts.boundSourceId && req.sourceId !== opts.boundSourceId) {
return { ok: false, protocol: 2, error: 'source_mismatch' };
}
try {
const budget = new Promise<'__budget__'>((r) => {
const t = setTimeout(() => r('__budget__'), TURN_CONTEXT_SERVER_BUDGET_MS);
t.unref?.();
});
const result = await Promise.race([handlers.turn_context(req), budget]);
if (result === '__budget__') {
return { ok: true, protocol: 2, block: null, degradedReason: 'server_budget' };
}
return {
ok: true,
protocol: 2,
block: result,
...(result?.degradedReason ? { degradedReason: result.degradedReason } : {}),
};
} catch (e) {
return { ok: false, protocol: 2, error: (e as Error).message };
}
}
/** Remove a socket file whose owning process is gone (or any leftover file). */
export function cleanupStaleSocket(socketPath: string): void {
try {
if (existsSync(socketPath)) {
// A unix socket shows up as a socket file; unlink unconditionally — if a
// live server holds it, listen() below would fail and we return null.
const st = statSync(socketPath);
if (st.isSocket() || st.isFIFO() || st.isFile()) unlinkSync(socketPath);
}
} catch {
/* best effort */
}
}