mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
03973c2fab
* fix(bin): pass search params via env vars (RCE fix) (#819) Replace shell string interpolation with process.env in gstack-learnings-search to prevent arbitrary code execution via crafted learnings entries. Also fixes the CROSS_PROJECT interpolation that the original PR missed. Adds 3 regression tests verifying no shell interpolation remains in the bun -e block. Co-authored-by: garagon <garagon@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): add path validation to upload command (#821) Add isPathWithin() and path traversal checks to the upload command, blocking file exfiltration via crafted upload paths. Uses existing SAFE_DIRECTORIES constant instead of a local copy. Adds 3 regression tests. Co-authored-by: garagon <garagon@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): symlink resolution in meta-commands validateOutputPath (#820) Add realpathSync to validateOutputPath in meta-commands.ts to catch symlink-based directory escapes in screenshot, pdf, and responsive commands. Resolves SAFE_DIRECTORIES through realpathSync to handle macOS /tmp -> /private/tmp symlinks. Existing path validation tests pass with the hardened implementation. Co-authored-by: garagon <garagon@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add uninstall instructions to README (#812) Community PR #812 by @0531Kim. Adds two uninstall paths: the gstack-uninstall script (handles everything) and manual removal steps for when the repo isn't cloned. Includes CLAUDE.md cleanup note and Playwright cache guidance. Co-Authored-By: 0531Kim <0531Kim@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): Windows launcher extraEnv + headed-mode token (#822) Community PR #822 by @pieterklue. Three fixes: 1. Windows launcher now merges extraEnv into spawned server env (was only passing BROWSE_STATE_FILE, dropping all other env vars) 2. Welcome page fallback serves inline HTML instead of about:blank redirect (avoids ERR_UNSAFE_REDIRECT on Windows) 3. /health returns auth token in headed mode even without Origin header (fixes Playwright Chromium extensions that don't send it) Also adds HOME/USERPROFILE fallback for cross-platform compatibility. Co-Authored-By: pieterklue <pieterklue@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): terminate orphan server when parent process exits (#808) Community PR #808 by @mmporong. Passes BROWSE_PARENT_PID to the spawned server process. The server polls every 15s with signal 0 and calls shutdown() if the parent is gone. Prevents orphaned chrome-headless-shell processes when Claude Code sessions exit abnormally. Co-Authored-By: mmporong <mmporong@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(security): IPv6 ULA blocking, cookie redaction, per-tab cancel, targeted token (#664) Community PR #664 by @mr-k-man (security audit round 1, new parts only). - IPv6 ULA prefix blocking (fc00::/7) in url-validation.ts with false-positive guard for hostnames like fd.example.com - Cookie value redaction for tokens, API keys, JWTs in browse cookies command - Per-tab cancel files in killAgent() replacing broken global kill-signal - design/serve.ts: realpathSync upgrade prevents symlink bypass in /api/reload - extension: targeted getToken handler replaces token-in-health-broadcast - Supabase migration 003: column-level GRANT restricts anon UPDATE scope - Telemetry sync: upsert error logging - 10 new tests for IPv6, cookie redaction, DNS rebinding, path traversal Co-Authored-By: mr-k-man <mr-k-man@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(security): CSS injection guard, timeout clamping, session validation, tests (#806) Community PR #806 by @mr-k-man (security audit round 2, new parts only). - CSS value validation (DANGEROUS_CSS) in cdp-inspector, write-commands, extension inspector - Queue file permissions (0o700/0o600) in cli, server, sidebar-agent - escapeRegExp for frame --url ReDoS fix - Responsive screenshot path validation with validateOutputPath - State load cookie filtering (reject localhost/.internal/metadata cookies) - Session ID format validation in loadSession - /health endpoint: remove currentUrl and currentMessage fields - QueueEntry interface + isValidQueueEntry validator for sidebar-agent - SIGTERM->SIGKILL escalation in timeout handler - Viewport dimension clamping (1-16384), wait timeout clamping (1s-300s) - Cookie domain validation in cookie-import and cookie-import-browser - DocumentFragment-based tab switching (XSS fix in sidepanel) - pollInProgress reentrancy guard for pollChat - toggleClass/injectCSS input validation in extension inspector - Snapshot annotated path validation with realpathSync - 714-line security-audit-r2.test.ts + 33-line learnings-injection.test.ts Co-Authored-By: mr-k-man <mr-k-man@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.15.13.0) Community security wave: 8 PRs from 4 contributors (@garagon, @mr-k-man, @mmporong, @0531Kim, @pieterklue). IPv6 ULA blocking, cookie redaction, per-tab cancel signaling, CSS injection guards, timeout clamping, session validation, DocumentFragment XSS fix, parent process watchdog, uninstall docs, Windows fixes, and 750+ lines of security regression tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: garagon <garagon@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: 0531Kim <0531Kim@users.noreply.github.com> Co-authored-by: pieterklue <pieterklue@users.noreply.github.com> Co-authored-by: mmporong <mmporong@users.noreply.github.com> Co-authored-by: mr-k-man <mr-k-man@users.noreply.github.com>
256 lines
8.2 KiB
TypeScript
256 lines
8.2 KiB
TypeScript
/**
|
|
* HTTP server for the design comparison board feedback loop.
|
|
*
|
|
* Replaces the broken file:// + DOM polling approach. The server:
|
|
* 1. Serves the comparison board HTML over HTTP
|
|
* 2. Injects __GSTACK_SERVER_URL so the board POSTs feedback here
|
|
* 3. Prints feedback JSON to stdout (agent reads it)
|
|
* 4. Stays alive across regeneration rounds (stateful)
|
|
* 5. Auto-opens in the user's default browser
|
|
*
|
|
* State machine:
|
|
*
|
|
* SERVING ──(POST submit)──► DONE ──► exit 0
|
|
* │
|
|
* ├──(POST regenerate/remix)──► REGENERATING
|
|
* │ │
|
|
* │ (POST /api/reload)
|
|
* │ │
|
|
* │ ▼
|
|
* │ RELOADING ──► SERVING
|
|
* │
|
|
* └──(timeout)──► exit 1
|
|
*
|
|
* Feedback delivery (two channels, both always active):
|
|
* Stdout: feedback JSON (one line per event) — for foreground mode
|
|
* Disk: feedback-pending.json (regenerate/remix) or feedback.json (submit)
|
|
* written next to the HTML file — for background mode polling
|
|
*
|
|
* The agent typically backgrounds $D serve and polls for feedback-pending.json.
|
|
* When found: read it, delete it, generate new variants, POST /api/reload.
|
|
*
|
|
* Stderr: structured telemetry (SERVE_STARTED, SERVE_FEEDBACK_RECEIVED, etc.)
|
|
*/
|
|
|
|
import fs from "fs";
|
|
import os from "os";
|
|
import path from "path";
|
|
import { spawn } from "child_process";
|
|
|
|
export interface ServeOptions {
|
|
html: string;
|
|
port?: number;
|
|
hostname?: string; // default '127.0.0.1' — localhost only
|
|
timeout?: number; // seconds, default 600 (10 min)
|
|
}
|
|
|
|
type ServerState = "serving" | "regenerating" | "done";
|
|
|
|
export async function serve(options: ServeOptions): Promise<void> {
|
|
const { html, port = 0, hostname = '127.0.0.1', timeout = 600 } = options;
|
|
|
|
// Validate HTML file exists
|
|
if (!fs.existsSync(html)) {
|
|
console.error(`SERVE_ERROR: HTML file not found: ${html}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Security: anchor all file reads to the initial HTML's directory.
|
|
// Prevents /api/reload from reading arbitrary files via path traversal.
|
|
const allowedDir = fs.realpathSync(path.dirname(path.resolve(html)));
|
|
|
|
let htmlContent = fs.readFileSync(html, "utf-8");
|
|
let state: ServerState = "serving";
|
|
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const server = Bun.serve({
|
|
port,
|
|
hostname,
|
|
fetch(req) {
|
|
const url = new URL(req.url);
|
|
|
|
// Serve the comparison board HTML
|
|
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
|
|
// Inject the server URL so the board can POST feedback
|
|
const injected = htmlContent.replace(
|
|
"</head>",
|
|
`<script>window.__GSTACK_SERVER_URL = '${url.origin}';</script>\n</head>`
|
|
);
|
|
return new Response(injected, {
|
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
});
|
|
}
|
|
|
|
// Progress polling endpoint (used by board during regeneration)
|
|
if (req.method === "GET" && url.pathname === "/api/progress") {
|
|
return Response.json({ status: state });
|
|
}
|
|
|
|
// Feedback submission from the board
|
|
if (req.method === "POST" && url.pathname === "/api/feedback") {
|
|
return handleFeedback(req);
|
|
}
|
|
|
|
// Reload endpoint (used by the agent to swap in new board HTML)
|
|
if (req.method === "POST" && url.pathname === "/api/reload") {
|
|
return handleReload(req);
|
|
}
|
|
|
|
return new Response("Not found", { status: 404 });
|
|
},
|
|
});
|
|
|
|
const actualPort = server.port;
|
|
const boardUrl = `http://127.0.0.1:${actualPort}`;
|
|
|
|
console.error(`SERVE_STARTED: port=${actualPort} html=${html}`);
|
|
|
|
// Auto-open in user's default browser
|
|
openBrowser(boardUrl);
|
|
|
|
// Set timeout
|
|
timeoutTimer = setTimeout(() => {
|
|
console.error(`SERVE_TIMEOUT: after=${timeout}s`);
|
|
server.stop();
|
|
process.exit(1);
|
|
}, timeout * 1000);
|
|
|
|
async function handleFeedback(req: Request): Promise<Response> {
|
|
let body: any;
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
|
}
|
|
|
|
// Validate expected shape
|
|
if (typeof body !== "object" || body === null) {
|
|
return Response.json({ error: "Expected JSON object" }, { status: 400 });
|
|
}
|
|
|
|
const isSubmit = body.regenerated === false;
|
|
const isRegenerate = body.regenerated === true;
|
|
const action = isSubmit ? "submitted" : (body.regenerateAction || "regenerate");
|
|
|
|
console.error(`SERVE_FEEDBACK_RECEIVED: type=${action}`);
|
|
|
|
// Print feedback JSON to stdout (for foreground mode)
|
|
console.log(JSON.stringify(body));
|
|
|
|
// ALWAYS write feedback to disk so the agent can poll for it
|
|
// (agent typically backgrounds $D serve, can't read stdout)
|
|
const feedbackDir = path.dirname(html);
|
|
const feedbackFile = isSubmit ? "feedback.json" : "feedback-pending.json";
|
|
const feedbackPath = path.join(feedbackDir, feedbackFile);
|
|
fs.writeFileSync(feedbackPath, JSON.stringify(body, null, 2));
|
|
|
|
if (isSubmit) {
|
|
state = "done";
|
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
|
|
// Give the response time to send before exiting
|
|
setTimeout(() => {
|
|
server.stop();
|
|
process.exit(0);
|
|
}, 100);
|
|
|
|
return Response.json({ received: true, action: "submitted" });
|
|
}
|
|
|
|
if (isRegenerate) {
|
|
state = "regenerating";
|
|
// Reset timeout for regeneration (agent needs time to generate new variants)
|
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
timeoutTimer = setTimeout(() => {
|
|
console.error(`SERVE_TIMEOUT: after=${timeout}s (during regeneration)`);
|
|
server.stop();
|
|
process.exit(1);
|
|
}, timeout * 1000);
|
|
|
|
return Response.json({ received: true, action: "regenerate" });
|
|
}
|
|
|
|
return Response.json({ received: true, action: "unknown" });
|
|
}
|
|
|
|
async function handleReload(req: Request): Promise<Response> {
|
|
let body: any;
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
|
}
|
|
|
|
const newHtmlPath = body.html;
|
|
if (!newHtmlPath || !fs.existsSync(newHtmlPath)) {
|
|
return Response.json(
|
|
{ error: `HTML file not found: ${newHtmlPath}` },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Security: resolve symlinks and validate the reload path is within the
|
|
// allowed directory (anchored to the initial HTML file's parent).
|
|
// Prevents path traversal via /api/reload reading arbitrary files.
|
|
const resolvedReload = fs.realpathSync(path.resolve(newHtmlPath));
|
|
if (!resolvedReload.startsWith(allowedDir + path.sep) && resolvedReload !== allowedDir) {
|
|
return Response.json(
|
|
{ error: `Path must be within: ${allowedDir}` },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
// Swap the HTML content
|
|
htmlContent = fs.readFileSync(resolvedReload, "utf-8");
|
|
state = "serving";
|
|
|
|
console.error(`SERVE_RELOADED: html=${newHtmlPath}`);
|
|
|
|
// Reset timeout
|
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
timeoutTimer = setTimeout(() => {
|
|
console.error(`SERVE_TIMEOUT: after=${timeout}s`);
|
|
server.stop();
|
|
process.exit(1);
|
|
}, timeout * 1000);
|
|
|
|
return Response.json({ reloaded: true });
|
|
}
|
|
|
|
// Keep the process alive
|
|
await new Promise(() => {});
|
|
}
|
|
|
|
/**
|
|
* Open a URL in the user's default browser.
|
|
* Handles macOS (open), Linux (xdg-open), and headless environments.
|
|
*/
|
|
function openBrowser(url: string): void {
|
|
const platform = process.platform;
|
|
let cmd: string;
|
|
|
|
if (platform === "darwin") {
|
|
cmd = "open";
|
|
} else if (platform === "linux") {
|
|
cmd = "xdg-open";
|
|
} else {
|
|
// Windows or unknown — just print the URL
|
|
console.error(`SERVE_BROWSER_MANUAL: url=${url}`);
|
|
console.error(`Open this URL in your browser: ${url}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const child = spawn(cmd, [url], {
|
|
stdio: "ignore",
|
|
detached: true,
|
|
});
|
|
child.unref();
|
|
console.error(`SERVE_BROWSER_OPENED: url=${url}`);
|
|
} catch {
|
|
// open/xdg-open not available (headless CI environment)
|
|
console.error(`SERVE_BROWSER_MANUAL: url=${url}`);
|
|
console.error(`Open this URL in your browser: ${url}`);
|
|
}
|
|
}
|