From 127eb33bc651da691fe24eca4e43052a788aa934 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 15 Aug 2026 17:01:41 -0700 Subject: [PATCH] fix(gbrain): DSNs percent-encode the password; body reads retry; stdout drains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three codex-adversarial findings in the provision port: (1) raw DB_PASS interpolation — a reserved character (/ # ? % @) restructured the URI, provisioning succeeded, and every consumer then failed to parse the DSN (unusable billable orphan); now encodeURIComponent, round-trip pinned. (2) await res.text() sat outside the transport try — a server that sent headers then reset the stream was an uncaught exit 1 instead of a retry-then-exit-8. (3) The bin entrypoint called process.exit() after unawaited stdout writes, truncating piped JSON; exitCode lets writes drain. --- bin/gstack-gbrain-supabase-provision | 7 +++++-- lib/gbrain-supabase-provision.ts | 12 ++++++++++-- test/gbrain-supabase-provision.test.ts | 21 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/bin/gstack-gbrain-supabase-provision b/bin/gstack-gbrain-supabase-provision index e52d3c994..c3d3029a6 100755 --- a/bin/gstack-gbrain-supabase-provision +++ b/bin/gstack-gbrain-supabase-provision @@ -17,10 +17,13 @@ import { runProvision } from '../lib/gbrain-supabase-provision'; +// exitCode, not process.exit(): exit() drops pending stdout writes, which +// truncates piped JSON / large listings; setting exitCode lets writes drain +// and the process exit naturally. runProvision(process.argv.slice(2)).then( - (code) => process.exit(code), + (code) => { process.exitCode = code; }, (error) => { process.stderr.write(`gstack-gbrain-supabase-provision: ${(error as Error)?.stack ?? error}\n`); - process.exit(1); + process.exitCode = 1; }, ); diff --git a/lib/gbrain-supabase-provision.ts b/lib/gbrain-supabase-provision.ts index 76eecbff4..2b162d1b1 100644 --- a/lib/gbrain-supabase-provision.ts +++ b/lib/gbrain-supabase-provision.ts @@ -250,6 +250,7 @@ async function apiCall(ctx: Ctx, method: string, apipath: string, body?: string) } let res: Response; + let text: string; try { res = await ctx.fetchImpl(url, { method, @@ -262,6 +263,10 @@ async function apiCall(ctx: Ctx, method: string, apipath: string, body?: string) body, signal: AbortSignal.timeout(CURL_TIMEOUT_MS), }); + // Body read stays INSIDE the transport try: a server that sends + // headers then resets or stalls the stream is a transport failure + // (retry, then exit 8) — not an uncaught exception at exit 1. + text = await res.text(); } catch { // Transport failure (connect refused, timeout, DNS). Best-effort // outcome record, then retry — same as the bash curl-failed branch. @@ -278,7 +283,6 @@ async function apiCall(ctx: Ctx, method: string, apipath: string, body?: string) continue; } - const text = await res.text(); try { writeOutcome({ env: ctx.env, receipt: receiptId, status: 'exit:0' }); } catch { @@ -508,7 +512,11 @@ async function cmdPoolerUrl(ctx: Ctx, args: string[]): Promise { poolMode = 'session'; } - const url = `postgresql://${dbUser}:${dbPass}@${dbHost}:${dbPort}/${dbName}`; + // Percent-encode the password segment: DB_PASS is caller-controlled and a + // reserved character (/ # ? % @) changes URI structure — the project + // provisions fine and then every consumer fails to parse the DSN, leaving + // an unusable billable orphan. + const url = `postgresql://${dbUser}:${encodeURIComponent(dbPass)}@${dbHost}:${dbPort}/${dbName}`; if (jsonMode) { ctx.stdout(JSON.stringify({ ref, pooler_url: url }, null, 2) + '\n'); diff --git a/test/gbrain-supabase-provision.test.ts b/test/gbrain-supabase-provision.test.ts index a0d2a981e..b4eff473d 100644 --- a/test/gbrain-supabase-provision.test.ts +++ b/test/gbrain-supabase-provision.test.ts @@ -403,6 +403,27 @@ describe('pooler-url', () => { expect(j.pooler_url).not.toContain('[PASSWORD]'); }); + test('percent-encodes reserved characters in DB_PASS (DSN stays parseable)', async () => { + // Raw interpolation of a password containing / # ? % @ changes URI + // structure: provisioning succeeds, every consumer then fails to parse + // the DSN — an unusable billable orphan. + mock = startMock({ + [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp(POOLER_OK), + }); + const r = await runCmd(['pooler-url', REF, '--json'], { + SUPABASE_ACCESS_TOKEN: 'sbp_test', + DB_PASS: 'p@ss/w#rd?100%', + SUPABASE_API_BASE: mock.url, + }); + expect(r.status).toBe(0); + const j = JSON.parse(r.stdout); + expect(j.pooler_url).toBe( + `postgresql://postgres.${REF}:${encodeURIComponent('p@ss/w#rd?100%')}@aws-0-us-east-1.pooler.supabase.com:6543/postgres` + ); + // The password segment must parse back out intact. + expect(decodeURIComponent(new URL(j.pooler_url).password)).toBe('p@ss/w#rd?100%'); + }); + test('handles array response by preferring session pool_mode entry', async () => { mock = startMock({ [`GET /v1/projects/${REF}/config/database/pooler`]: () =>