diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 786c50255..8aa703e4b 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -39,6 +39,7 @@ import "../lib/conductor-env-shim"; import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers"; import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, type CycleStatus } from "../lib/gbrain-sources"; import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards"; +import { writeReceipt } from "../lib/egress-receipt"; import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status"; import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec"; import { checkOwnedStagingDir } from "../lib/staging-guard"; @@ -67,7 +68,13 @@ interface CodeStageDetail { source_path?: string; page_count?: number | null; last_imported?: string; - status?: "ok" | "skipped" | "failed" | "refused-autopilot" | "refused-reclone"; + status?: + | "ok" + | "skipped" + | "failed" + | "refused-autopilot" + | "refused-reclone" + | "refused-egress-receipt"; } interface StageResult { @@ -902,6 +909,27 @@ async function runCodeImport(args: CliArgs): Promise { }; } + // Egress receipt BEFORE the code walk (fail-closed): the walk ships repo + // content to the user's gbrain DB, which may be a remote Postgres. The + // gbrain subprocess owns the wire bytes, so the receipt is content-free + // (destination + payload class only; sha256 null). + try { + writeReceipt({ + sink: "gbrain-sync", + host: "gbrain-db (user-configured DATABASE_URL)", + payloadClass: `repo-code-index source=${sourceId} (sent by gbrain subprocess)`, + bytes: 0, + sha256: null, + consent: "gbrain setup consent + per-repo policy chokepoint (repoPolicyTier)", + }); + } catch (err) { + return { + name: "code", ran: true, ok: false, duration_ms: Date.now() - t0, + summary: `EGRESS_RECEIPT_FAILED: ${(err as Error).message} — code sync refused`, + detail: { source_id: sourceId, source_path: root, status: "refused-egress-receipt" }, + }; + } + const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], { stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], timeout: codeTimeoutMs, diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index 653d4069a..532aee4a9 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -65,6 +65,7 @@ import { withErrorContext, } from "../lib/gstack-memory-helpers"; import { execGbrainText, spawnGbrainAsync } from "../lib/gbrain-exec"; +import { writeReceipt } from "../lib/egress-receipt"; import { checkOwnedStagingDir, STAGING_MARKER } from "../lib/staging-guard"; // ── Types ────────────────────────────────────────────────────────────────── @@ -1690,6 +1691,36 @@ async function ingestPass(args: CliArgs): Promise { // spawn, parent termination orphans the gbrain process (observed // during 2026-05-10 cold-run testing — gbrain kept running 15 min // after the orchestrator timed out). + // + // Egress receipt BEFORE the import (fail-closed): the gbrain DB may be a + // remote Postgres, so the ingest is a potential off-machine send. The + // gbrain subprocess owns the wire bytes (content-free receipt, sha256 + // null). The remote-http branch above stages locally only — its egress + // happens in gstack-brain-sync, which writes its own receipt at the push. + try { + writeReceipt({ + sink: "memory-ingest", + host: "gbrain-db (user-configured DATABASE_URL)", + payloadClass: `transcript-pages count=${staging.written} (sent by gbrain subprocess)`, + bytes: 0, + sha256: null, + consent: "gbrain setup consent (/setup-gbrain)", + }); + } catch (err) { + const msg = `EGRESS_RECEIPT_FAILED: ${(err as Error).message} — ingest refused`; + console.error(`[memory-ingest] ERR: ${msg}`); + failed += prep.prepared.length; + return { + written: 0, + skipped_secret: prep.skippedSecret, + skipped_dedup: prep.skippedDedup, + skipped_unattributed: prep.skippedUnattributed, + failed, + duration_ms: Date.now() - t0, + partial_pages: prep.partialPages, + system_error: msg, + }; + } const importResult = await runGbrainImport(stagingDir, resolveImportTimeoutMs()); const stdout = importResult.stdout || ""; diff --git a/browse/src/server.ts b/browse/src/server.ts index 301781acc..bed7fb9ab 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -49,6 +49,7 @@ import { isProcessAlive } from './error-handling'; import { sanitizeBody, stripLoneSurrogateEscapes } from './sanitize'; import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge'; import { parseProxyConfig, toUpstreamConfig, ProxyConfigError } from './proxy-config'; +import { writeReceipt } from '../../lib/egress-receipt'; import { redactProxyUrl } from './proxy-redact'; import { shouldSpawnXvfb, pickFreeDisplay, spawnXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb'; import { logTunnelDenial } from './tunnel-denial-log'; @@ -2375,6 +2376,19 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { const forwardOpts: any = { addr: tunnelPort, authtoken }; if (domain) forwardOpts.domain = domain; + // Egress receipt BEFORE the tunnel session opens, fail-closed: a + // writeReceipt failure lands in this catch, which tears the tunnel + // listener back down and refuses the start. One receipt per session + // open; browse command behavior over the tunnel is unchanged. + writeReceipt({ + sink: 'browse-tunnel', + host: domain || 'connect.ngrok-agent.com', + payloadClass: 'tunnel-session-open (scoped-token browser-command surface)', + bytes: 0, + sha256: null, + consent: 'pair_agent=on', + }); + tunnelListener = await ngrok.forward(forwardOpts); tunnelUrl = tunnelListener.url(); tunnelServer = boundTunnel; @@ -3084,6 +3098,18 @@ export async function start() { const forwardOpts: any = { addr: tunnelPort, authtoken }; if (domain) forwardOpts.domain = domain; + // Egress receipt BEFORE the tunnel session opens, fail-closed: a + // writeReceipt failure lands in this catch, which cleans up the + // listener and skips the tunnel (same as any other startup failure). + writeReceipt({ + sink: 'browse-tunnel', + host: domain || 'connect.ngrok-agent.com', + payloadClass: 'tunnel-session-open (scoped-token browser-command surface)', + bytes: 0, + sha256: null, + consent: 'pair_agent=on (BROWSE_TUNNEL=1)', + }); + tunnelListener = await ngrok.forward(forwardOpts); tunnelUrl = tunnelListener.url(); tunnelServer = boundTunnel;