fix: pre-landing review fixes for the Aside-first branch

Review army + adversarial passes (Claude and Codex) on the merged branch:

setup
- _prune_stale_generated scans the host dirs too (the generator already
  removed the render before setup ran, so the host branch was dead), skips
  symlinks in the render tree (rm -rf on a slash-terminated link empties its
  target), removes a host symlink only when it resolves into gstack, cleans a
  bannered real dir through _cleanup_weak_dir, recognizes frontmatter-renamed
  skills, and logs through log. The always-run codex render passes every host
  dir that may link to it.
- NEEDS_BUILD checks all three binaries (with $_EXE) and lib/ sources; the
  browser hint and the bootstrap summary honor GSTACK_SKIP_ASIDE, treat a
  requested skip as a request, and derive one skill list.

lib/aside-render.ts + bin/gstack-render.ts
- The loopback server carries a per-render secret path, checks containment on
  the real path (symlink escapes are 403), and rejects malformed encoding.
- Inline eval results are one base64 line, so page text cannot forge
  ASIDE_DIR= or the sentinel; the last ASIDE_DIR wins.
- runProc escalates SIGTERM to SIGKILL, bounds every wait, and clears every
  timer (an uncleared one kept gstack-render alive after printing OK).
- renderTmpDir refuses a shared /tmp name owned by someone else; the work dir
  and server are created inside try; goto's budget follows the render budget.
- probeAside classifies a present-but-failing CLI as ASIDE_NOT_RUNNING like
  the skills' bash probe; render() retries on gstack's own browser when Aside
  could not start or its private CDP bridge is gone (never on a page error
  or a timeout of a running script); the CLI reports the engine that actually
  rendered, exits 0 on --help, rejects non-numeric flags, documents
  --wait-timeout, fences EVAL/PAGE_ERRORS as untrusted content, and names the
  daemon's cookie-import JS lock remedy.
- The browse path passes --scale only when asked (a scale change rebuilds
  the daemon context) and restores the viewport after a sized screenshot.

resolvers / templates
- The bash probe honors GSTACK_SKIP_ASIDE and has a perl deadline on stock
  macOS; .local is no longer LOCAL (mDNS); same-origin filters compare parsed
  origins; link status is HEAD-checked only on LOCAL targets; every
  aside exec goes through the receipted _aside_exec prelude
  ({{ASIDE_EXEC_PRELUDE}}), including nine template blocks that called it
  bare; the design sketch and diagram staging use private directories.
- The generator prunes only bannered renders and never a host whose
  generation failed.

Docs, stale comments and dead code cleaned; goldens re-rendered; tests
updated and added for every behavior above.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-06 07:23:26 +00:00
co-authored by Claude Fable 5.1
parent ea61bd65be
commit 444f8feff8
65 changed files with 2288 additions and 991 deletions
+4 -7
View File
@@ -12,11 +12,13 @@ import * as path from "node:path";
import {
NO_BROWSER,
PAGE_NUMBER_FOOTER,
lengthToInches,
paperInches,
render,
renderTmpDir,
type PdfStepOptions,
type RenderEngine,
} from "../../lib/aside-render";
import { BrowserUnavailableError } from "./types";
@@ -40,12 +42,6 @@ export interface PdfOptions {
toc?: boolean;
}
const PAGE_NUMBER_FOOTER =
'<div style="font-size:9pt; font-family:Helvetica,Arial,sans-serif; color:#666; ' +
'width:100%; text-align:center;">' +
'<span class="pageNumber"></span> of <span class="totalPages"></span>' +
"</div>";
/**
* make-pdf's option shape → CDP Page.printToPDF options (inches). Same
* mapping the browse `pdf` command applies: Letter when no size is
@@ -94,7 +90,7 @@ export async function renderPdf(
html: string,
opts: PdfOptions,
renderFn: typeof render = render,
): Promise<void> {
): Promise<RenderEngine | undefined> {
const dir = fs.mkdtempSync(path.join(renderTmpDir(), "make-pdf-"));
try {
const file = path.join(dir, "document.html");
@@ -104,6 +100,7 @@ export async function renderPdf(
steps: [{ kind: "pdf", out: path.resolve(opts.output), options: pdfStepOptions(opts) }],
});
if (!result.ok) throw renderFailure(result.error ?? "unknown error");
return result.engine;
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
+7 -1
View File
@@ -221,7 +221,7 @@ export async function generate(opts: GenerateOptions): Promise<string> {
const engine = pickEngine().engine;
const via = engine === "aside" ? "Aside" : engine === "browse" ? "gstack's browser" : "a browser";
progress.begin(`Rendering PDF through ${via}`);
await renderPdf(finalHtml, {
const used = await renderPdf(finalHtml, {
output: outputPath,
format: opts.pageSize ?? "letter",
marginTop: opts.marginTop ?? opts.margins ?? "1in",
@@ -244,6 +244,12 @@ export async function generate(opts: GenerateOptions): Promise<string> {
toc: opts.toc,
});
progress.end(`Rendering PDF through ${via}`);
if (used && used !== engine) {
// render() fell back mid-run (Aside quit or its CLI could not start): say
// which browser actually produced the file, since the label above was
// decided before the render.
process.stderr.write(" Aside was unavailable mid-run; the PDF was rendered through gstack's own browser.\n");
}
const kb = Math.round(fs.statSync(outputPath).size / 1024);
progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath}`);
+5 -3
View File
@@ -12,7 +12,8 @@
import * as path from "node:path";
import * as fs from "node:fs";
import { pickEngine, render, renderTmpDir } from "../../lib/aside-render";
import { NO_BROWSER_HELP, pickEngine, render, renderTmpDir } from "../../lib/aside-render";
import { ExitCode } from "./types";
import { resolvePdftotext, PdftotextUnavailableError } from "./pdftotext";
import { OUTPUT_TMP_DIR, generate } from "./orchestrator";
@@ -25,7 +26,7 @@ export async function runSetup(): Promise<void> {
if (!engine.engine) {
process.stderr.write(" FAIL\n");
process.stderr.write(`\n${engine.error}\n`);
process.exit(4);
process.exit(ExitCode.BrowserUnavailable);
}
const via = engine.engine === "aside" ? `Aside ${engine.version}` : "gstack browser";
process.stderr.write(engine.engine === "aside"
@@ -46,7 +47,8 @@ export async function runSetup(): Promise<void> {
} catch (err: any) {
process.stderr.write(" FAIL\n");
process.stderr.write(`\n${via} could not render a page: ${err.message}\n`);
process.exit(4);
process.stderr.write(`To fix: ${NO_BROWSER_HELP}\n`);
process.exit(ExitCode.BrowserUnavailable);
} finally {
fs.rmSync(smokeDir, { recursive: true, force: true });
}