From 76f76abd3f04a9b02e4c89c9d02d92d2d2ce72a3 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Sat, 16 May 2026 19:48:37 -0400 Subject: [PATCH 1/4] feat(B-0581): decompose slice 1 - gh auth refresh wrapper script --- tools/auth/gh-auth-refresh-wrapper.ts | 64 +++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tools/auth/gh-auth-refresh-wrapper.ts diff --git a/tools/auth/gh-auth-refresh-wrapper.ts b/tools/auth/gh-auth-refresh-wrapper.ts new file mode 100644 index 0000000000..0308904cfa --- /dev/null +++ b/tools/auth/gh-auth-refresh-wrapper.ts @@ -0,0 +1,64 @@ +import { spawn } from "bun"; + +async function main() { + const scopes = process.argv.slice(2).join(","); + if (!scopes) { + console.error("Usage: bun run tools/auth/gh-auth-refresh-wrapper.ts "); + process.exit(1); + } + + console.log(`Starting gh auth refresh for scopes: ${scopes}`); + + const proc = spawn({ + cmd: ["gh", "auth", "refresh", "-h", "github.com", "-s", scopes], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + const decoder = new TextDecoder(); + let codeCaptured = false; + + const handleOutput = async (stream: ReadableStream, isStderr: boolean) => { + const out = isStderr ? process.stderr : process.stdout; + for await (const chunk of stream) { + const text = decoder.decode(chunk); + out.write(text); + + // Look for the Y/N prompt + if (text.includes("? Authenticate Git with your GitHub credentials?")) { + proc.stdin.write("Y\n"); + proc.stdin.flush(); + } + + // Look for the one-time code + const codeMatch = text.match(/! First copy your one-time code: ([A-Z0-9-]+)/); + if (codeMatch && !codeCaptured) { + const code = codeMatch[1]; + codeCaptured = true; + console.log(`\n\n`); + console.log(`========================================================`); + console.log(`🚀 ONE-TIME CODE CAPTURED: ${code} 🚀`); + console.log(`========================================================`); + console.log(`\n`); + + // Optionally pump Enter if the process expects it, but wait for user to copy. + // The script just needs to surface it prominently for now. + } + } + }; + + Promise.all([ + handleOutput(proc.stdout, false), + handleOutput(proc.stderr, true) + ]).catch(console.error); + + const exitCode = await proc.exited; + console.log(`\ngh auth refresh exited with code ${exitCode}`); + process.exit(exitCode); +} + +main().catch((err) => { + console.error("Error running wrapper:", err); + process.exit(1); +}); From f320abc7baff1e18fecc0d2ae1c73c7d1eb52594 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Mon, 18 May 2026 20:50:10 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(B-0581):=20address=203=20reviewer=20thr?= =?UTF-8?q?eads=20=E2=80=94=20buffer=20prompt=20across=20chunks=20(Codex?= =?UTF-8?q?=20P1)=20+=20await=20output=20pumps=20before=20exit=20(Codex=20?= =?UTF-8?q?P2)=20+=20export=20main()=20+=20import.meta.main=20guard=20(Cop?= =?UTF-8?q?ilot=20convention)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/auth/gh-auth-refresh-wrapper.ts | 55 ++++++++++++++++++--------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/tools/auth/gh-auth-refresh-wrapper.ts b/tools/auth/gh-auth-refresh-wrapper.ts index 0308904cfa..e9412d57e5 100644 --- a/tools/auth/gh-auth-refresh-wrapper.ts +++ b/tools/auth/gh-auth-refresh-wrapper.ts @@ -1,14 +1,14 @@ import { spawn } from "bun"; -async function main() { +export async function main(): Promise { const scopes = process.argv.slice(2).join(","); if (!scopes) { console.error("Usage: bun run tools/auth/gh-auth-refresh-wrapper.ts "); - process.exit(1); + return 1; } console.log(`Starting gh auth refresh for scopes: ${scopes}`); - + const proc = spawn({ cmd: ["gh", "auth", "refresh", "-h", "github.com", "-s", scopes], stdin: "pipe", @@ -21,18 +21,27 @@ async function main() { const handleOutput = async (stream: ReadableStream, isStderr: boolean) => { const out = isStderr ? process.stderr : process.stdout; + // Buffer text across chunks: gh output may split prompts across + // arbitrary chunk boundaries, so per-chunk includes() can miss them. + let buffer = ""; + const PROMPT = "? Authenticate Git with your GitHub credentials?"; + let promptHandled = false; for await (const chunk of stream) { const text = decoder.decode(chunk); out.write(text); - - // Look for the Y/N prompt - if (text.includes("? Authenticate Git with your GitHub credentials?")) { + buffer += text; + + // Look for the Y/N prompt in accumulated buffer. + if (!promptHandled && buffer.includes(PROMPT)) { proc.stdin.write("Y\n"); proc.stdin.flush(); + promptHandled = true; + // Trim consumed prompt to bound buffer growth. + buffer = buffer.slice(buffer.indexOf(PROMPT) + PROMPT.length); } - // Look for the one-time code - const codeMatch = text.match(/! First copy your one-time code: ([A-Z0-9-]+)/); + // Look for the one-time code in accumulated buffer. + const codeMatch = buffer.match(/! First copy your one-time code: ([A-Z0-9-]+)/); if (codeMatch && !codeCaptured) { const code = codeMatch[1]; codeCaptured = true; @@ -41,24 +50,34 @@ async function main() { console.log(`🚀 ONE-TIME CODE CAPTURED: ${code} 🚀`); console.log(`========================================================`); console.log(`\n`); - - // Optionally pump Enter if the process expects it, but wait for user to copy. - // The script just needs to surface it prominently for now. + } + + // Keep buffer bounded across long sessions. + if (buffer.length > 16384) { + buffer = buffer.slice(-8192); } } }; - Promise.all([ + // Capture handler promises and await them before exit; otherwise + // trailing stdout/stderr (e.g. the one-time code banner) can be + // dropped when process.exit fires before the output pumps drain. + const handlersPromise = Promise.all([ handleOutput(proc.stdout, false), - handleOutput(proc.stderr, true) + handleOutput(proc.stderr, true), ]).catch(console.error); const exitCode = await proc.exited; + await handlersPromise; console.log(`\ngh auth refresh exited with code ${exitCode}`); - process.exit(exitCode); + return exitCode; } -main().catch((err) => { - console.error("Error running wrapper:", err); - process.exit(1); -}); +if (import.meta.main) { + main() + .then((code) => process.exit(code)) + .catch((err) => { + console.error("Error running wrapper:", err); + process.exit(1); + }); +} From 4aa1e02affb1d72ef6438a5bdad87485ff5373aa Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Mon, 18 May 2026 20:55:34 -0400 Subject: [PATCH 3/4] fix(B-0581): handle gh's post-code Enter prompt (Codex P1 follow-up after #31086ff) --- tools/auth/gh-auth-refresh-wrapper.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/auth/gh-auth-refresh-wrapper.ts b/tools/auth/gh-auth-refresh-wrapper.ts index e9412d57e5..74f6023049 100644 --- a/tools/auth/gh-auth-refresh-wrapper.ts +++ b/tools/auth/gh-auth-refresh-wrapper.ts @@ -50,6 +50,13 @@ export async function main(): Promise { console.log(`🚀 ONE-TIME CODE CAPTURED: ${code} 🚀`); console.log(`========================================================`); console.log(`\n`); + // gh waits at "Press Enter to open github.com in your browser..." + // after printing the code. Pump a newline so the device flow + // continues to the token-store step; without this the wrapper + // hangs indefinitely because the child stdin is piped and the + // operator can't satisfy the prompt manually. + proc.stdin.write("\n"); + proc.stdin.flush(); } // Keep buffer bounded across long sessions. From 3b2dc0b5bd884178e4775f2c143effa97d232a48 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Mon, 18 May 2026 22:11:16 -0400 Subject: [PATCH 4/4] fix(B-0581 wrapper): move TextDecoder inside handleOutput to avoid concurrent-stream state corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextDecoder is stateful — sharing one across two concurrent handleOutput async loops (stdout + stderr) can corrupt output when partial multi-byte sequences from one stream prepend to the next chunk of the other. Resolves Copilot review thread on PR #3979. Co-Authored-By: Claude --- tools/auth/gh-auth-refresh-wrapper.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/auth/gh-auth-refresh-wrapper.ts b/tools/auth/gh-auth-refresh-wrapper.ts index 74f6023049..a69b71aeca 100644 --- a/tools/auth/gh-auth-refresh-wrapper.ts +++ b/tools/auth/gh-auth-refresh-wrapper.ts @@ -16,11 +16,14 @@ export async function main(): Promise { stderr: "pipe", }); - const decoder = new TextDecoder(); let codeCaptured = false; const handleOutput = async (stream: ReadableStream, isStderr: boolean) => { const out = isStderr ? process.stderr : process.stdout; + // Per-invocation TextDecoder: TextDecoder is stateful (retains partial + // multi-byte sequences between decode() calls). Two concurrent handleOutput + // calls (stdout + stderr) sharing one decoder can corrupt cross-stream output. + const decoder = new TextDecoder(); // Buffer text across chunks: gh output may split prompts across // arbitrary chunk boundaries, so per-chunk includes() can miss them. let buffer = "";