From 7533d2405bada9b34df8c979cbb6e5edd414e340 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Sun, 27 Sep 2026 16:43:33 -0400 Subject: [PATCH] test(release): smoke packed interactive 401 recovery --- .github/workflows/ci.yml | 6 ++ .github/workflows/release.yml | 6 ++ scripts/packed-repl-auth-smoke.ts | 135 ++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 scripts/packed-repl-auth-smoke.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cb8250e..61631d59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,6 +171,12 @@ jobs: " shell: bash + - name: Recover the installed interactive REPL from a stalled 401 + run: | + cd "${RUNNER_TEMP}/cleanroom" + node "${GITHUB_WORKSPACE}/dist/scripts/packed-repl-auth-smoke.js" + shell: bash + - name: The installed package has the exact reviewed dependency graph run: | set -euo pipefail diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16b5aa74..2f520f6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,6 +87,12 @@ jobs: test "$("$prefix/bin/aether" --version)" = "${RELEASE_TAG#v}" "$prefix/bin/aether" --help > /dev/null + - name: Recover installed interactive chat from a stalled 401 + shell: bash + run: | + prefix="$RUNNER_TEMP/aether-install" + node dist/scripts/packed-repl-auth-smoke.js "$prefix/lib/node_modules/aether-agents/dist/src/main.js" + - name: Attest package provenance uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: diff --git a/scripts/packed-repl-auth-smoke.ts b/scripts/packed-repl-auth-smoke.ts new file mode 100644 index 00000000..98b6543c --- /dev/null +++ b/scripts/packed-repl-auth-smoke.ts @@ -0,0 +1,135 @@ +// Exercise the installed npm package's interactive chat path without a real +// credential or Cloud request. A 401 whose body never closes must return to the +// prompt within the configured stream bound, instead of parking the terminal. +import { spawn } from "node:child_process"; +import { createServer, type ServerResponse } from "node:http"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const INSTALL_DIR = resolve(process.cwd(), "node_modules", "aether-agents"); +const INSTALLED_ENTRY = process.argv[2] + ? resolve(process.argv[2]) + : join(INSTALL_DIR, "dist", "src", "main.js"); +const SMOKE_PROMPT = "just testing reply 1"; +const TIMEOUT_MS = 300; +const DEADLINE_MS = 8_000; + +interface ChildResult { + code: number | null; + stdout: string; + stderr: string; + submitted: boolean; + exitSent: boolean; + timedOut: boolean; +} + +async function runInstalledRepl(baseUrl: string, scratch: string): Promise { + // Pipes are used for deterministic CI input, while this preload enters the + // CLI's raw-key REPL branch. This covers the same input handler as a Windows + // console; a real ConPTY/desktop canary remains a separate live check. + const preload = join(scratch, "tty-preload.cjs"); + writeFileSync(preload, [ + "Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });", + "process.stdin.setRawMode = () => process.stdin;", + "Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });", + ].join("\n") + "\n"); + + const child = spawn(process.execPath, ["--require", preload, INSTALLED_ENTRY], { + cwd: scratch, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + env: { + ...process.env, + AETHER_CONFIG_DIR: scratch, + AETHER_BASE_URL: baseUrl, + AETHER_TOKEN: "aek_offline_release_smoke", + AETHER_BACKEND: "cloud", + AETHER_NO_HISTORY: "1", + AETHER_NO_ANIM: "1", + AETHER_STREAM_TIMEOUT_MS: String(TIMEOUT_MS), + AETHER_REQUEST_TIMEOUT_MS: String(TIMEOUT_MS), + NO_COLOR: "1", + TERM: "dumb", + }, + }); + let stdout = ""; + let stderr = ""; + let submitted = false; + let exitSent = false; + let timedOut = false; + let exitTimer: ReturnType | undefined; + const maybeDrive = (): void => { + if (!submitted && stdout.includes("Type a prompt, or /help for commands")) { + submitted = true; + child.stdin.write(`${SMOKE_PROMPT}\r`); + } + if (submitted && !exitSent && stderr.includes("✗")) { + exitSent = true; + // A failed submission is intentionally restored as a draft. Ctrl+C + // clears that draft, then /exit demonstrates that the REPL accepts input. + exitTimer = setTimeout(() => child.stdin.write("\x03/exit\r"), 100); + } + }; + child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); maybeDrive(); }); + child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); maybeDrive(); }); + const deadline = setTimeout(() => { timedOut = true; child.kill(); }, DEADLINE_MS); + try { + const code = await new Promise((done, fail) => { + child.once("error", fail); + child.once("exit", done); + }); + return { code, stdout, stderr, submitted, exitSent, timedOut }; + } finally { + clearTimeout(deadline); + if (exitTimer) clearTimeout(exitTimer); + child.kill(); + } +} + +async function main(): Promise { + if (!existsSync(INSTALLED_ENTRY)) { + throw new Error(`packed CLI entry missing: ${INSTALLED_ENTRY}`); + } + const scratch = mkdtempSync(join(tmpdir(), "aether-packed-repl-401-")); + let requests = 0; + const hangingResponses = new Set(); + const server = createServer((req, res) => { + if (req.url === "/cloud/agent/chat/stream" && req.method === "POST") { + requests += 1; + if (req.headers.authorization !== "Bearer aek_offline_release_smoke") { + res.writeHead(500).end("fixture token missing"); + return; + } + hangingResponses.add(res); + res.writeHead(401, { "content-type": "application/json" }); + res.write('{"detail":"unauthorized fixture'); + return; // deliberately never end the 401 body + } + res.writeHead(404).end("fixture route unavailable"); + }); + try { + await new Promise((done) => server.listen(0, "127.0.0.1", done)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("fixture did not bind a TCP port"); + const result = await runInstalledRepl(`http://127.0.0.1:${address.port}/cloud`, scratch); + if (result.timedOut || result.code !== 0 || !result.submitted || !result.exitSent || requests !== 1) { + throw new Error(`installed REPL did not recover from the 401: ${JSON.stringify({ + code: result.code, timedOut: result.timedOut, submitted: result.submitted, + exitSent: result.exitSent, requests, stdout: result.stdout.slice(-1600), + stderr: result.stderr.slice(-1600), + })}`); + } + if (!/timeout|timed out|HTTP 401/i.test(result.stderr)) { + throw new Error(`installed REPL did not show a terminal 401/timeout error: ${result.stderr.slice(-1600)}`); + } + process.stdout.write("packed interactive 401 recovery verified\n"); + } finally { + for (const response of hangingResponses) response.destroy(); + server.closeAllConnections(); + await new Promise((done) => server.close(() => done())); + rmSync(scratch, { recursive: true, force: true }); + } +} + +await main();