mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-01 19:00:40 +02:00
* fix(redact): block real all-caps URL passwords, not just shape-match urlPasswordIsPlaceholder skipped any password matching /^[A-Z][A-Z0-9_]*$/, so a real DSN like postgres://admin:PROD2026SECRET@db-prod.internal/app slipped the HIGH pre-push block. Replace the shape rule with an anchored, exact-match set of doc-convention placeholder tokens (PASSWORD, PASS, CHANGEME, ...), compared case-sensitively and never as a substring (PROD2026SECRET must not match SECRET). The USER:PASSWORD doc convention still suppresses; real all-caps and lowercase passwords block. Regression cases pinned both directions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): write self-contained .gstack/.gitignore unconditionally ensureStateDir only appended .gstack/ to the project .gitignore when that file already existed, skipped silently on ENOENT, and swallowed other append failures. With BROWSE_PERSIST_STATE=1, session-state.json (live cookies + localStorage/sessionStorage tokens) and browse-network.log / browse-audit.jsonl (request headers) then sat git-add-able under <git-root>/.gstack/. Write a self-contained <stateDir>/.gitignore containing "*" unconditionally, before return, so the state dir's contents can never be committed regardless of the project .gitignore. The project-.gitignore append is kept as redundant safety. The no-import-side-effects guard is relaxed to allow exactly this lone .gitignore guard file (still fails on browse.json / session-state.json / logs / listener binds) — the guard is written eagerly by ensureStateDir at import and is not leaked state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): restore Bun.spawn exited/drain/OOM-cap contract on Node polyfill The v1.65 fork-port squash silently dropped the `exited` promise, eager stdout/stderr drain, and 16MB GSTACK_SPAWN_MAX_BUFFER cap that v1.64 added (#2571), plus the five tests pinning them. On the Windows Node fallback, `await proc.exited` then resolved to undefined immediately — cookie-import, isBrowserRunning, and browser-skill children all read stdout before the child produced it, a silent failure. Re-land the block (keeping v1.65's windowsHide comment improvements) and re-add the pinning tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios-qa): compile the private-API touch bridge out of Release builds PR #2264 claimed DebugBridgeTouch.m (KIF-derived in-process touch synthesis using private UIKit/IOKit symbols: _touchesEvent, IOHIDEventCreateDigitizer*, _AXSSetAutomationEnabled) was "compiled out in Release," but the body was gated only by TARGET_OS_IOS, so a Release iOS build carried the private symbols (App Store rejection risk). The safety half of the fix (closed PR #2269) never landed. Gate the body on `#if TARGET_OS_IOS && DEBUG` and add the cSettings DEBUG define to the DebugBridgeTouch target so `#if DEBUG` is true in debug and false in release (mirrors the Core/UI swiftSettings). A free static tripwire pins both halves; the nm/strings symbol proof needs an iOS-SDK build and belongs in the device/periodic tier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(egress): state truncation/deletion of the ledger are out of scope gstack-egress verify catches in-place edits, reordering, and mid-chain deletion (the hash chain breaks) but not tail-truncation, whole-file re-fabrication, or deletion — a same-user local actor who owns the ledger defeats those and verify still exits 0. That matches the stated threat model (forensic observability, not an exfiltration control). Document it in the header threat model and the usage text rather than adding a count-sidecar, which would false-positive on every legitimate rotation and barely raise the bar. Head-anchoring stays the tracked rotation TODO in lib/egress-receipt.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ship): scope the App Store Connect key to one app and disclose it at exit The release flow minted a non-expiring APP_MANAGER key with allAppsVisible:true (standing authority over every app on the team) and was told never to mention any credential to the user, so the durable key never reached their revocation checklist. Scope the key to the app being released via the apps relationship (allAppsVisible:false + an explicit apps association — required, since a no-app key can see nothing and uploads fail), and disclose the key once in the closing report with its ASC revocation path. Carve the exit disclosure as the explicit exception to the mid-run no-credential-talk rule so the one-authorization-moment contract still holds. Edited the .tmpl source and regenerated the section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * harden(browse): constant-time bearer-token comparison in validateAuth The loopback auth check compared the Authorization header with `===`, whose byte-by-byte early exit leaks the token prefix through response timing. Use crypto.timingSafeEqual with a length gate (the length is not secret). Behavior is unchanged for valid/invalid tokens; auth tests unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: pin the security-property regression guards from pre-landing review The pre-landing review found the fixes were correct but three regression guards were missing — each pins a property whose silent revert would keep behavior identical while reopening the hole: - validateAuth: a static tripwire asserting crypto.timingSafeEqual + the got.length===want.length gate + the null-header guard (a revert to `===` keeps accept/reject green but restores the timing side-channel). - redact: a table-driven loop over the exported URL_PASSWORD_PLACEHOLDER_WORDS so a typo or dropped entry can't silently start blocking a doc placeholder; plus a substring-can't-rescue-a-real-secret assertion. - config: assert the self-contained .gitignore is written even when git already ignores .gstack/, proving the write precedes the isIgnoredByGit early return. - bun-polyfill: cover the 128+signal exit branch (POSIX only). URL_PASSWORD_PLACEHOLDER_WORDS is exported so the table test can't drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version and changelog (v1.66.2.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: sync egress-verify scope and layered iOS Release guard into user docs ARCHITECTURE.md and README.md now carry the same gstack-egress verify scope disclosure the CLI ships (edits/reordering/mid-chain deletion detected; tail-truncation and ledger deletion out of scope for a forensic log). docs/howto-ios-testing-with-gstack.md documents the second Release-build guard: DebugBridgeTouch.m compiles out behind #if TARGET_OS_IOS && DEBUG via the cSettings DEBUG define. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(ios-qa): call the DebugBridge targets SwiftPM targets, not Swift targets DebugBridgeTouch is Objective-C (the same sentence says so); "Swift targets" was the wrong word. Cross-model doc review catch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(changelog): describe the all-caps DSN examples without a scannable URL shape The v1.66.2.0 entry quoted its own headline fix as three literal postgres://user:PASSWORD@host examples — which the branch's stricter HIGH gate now correctly flags, failing CI's quality scan on this very PR (the local pre-push hook passed because the installed gstack still runs the old engine). Rewrite the three mentions: the reproduce command uses a fully-braced shell interpolation (suppressed in the diff scan by design, expands to the real all-caps password at runtime, still exits 3 — verified), and the table row + Fixed bullet name the password token without the URL shape. Gate scan on the amended diff: 0 high. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(evals): pre-seed one-time preamble markers for PTY smokes Root cause of the documented intermittent scope-gate-question-NOT-observed failure (test/skill-e2e-plan-mode-no-op.test.ts, also PR #2593 rounds 3/11): on a fresh runner every one-time preamble marker is missing, so each PTY child runs first-run feature discovery before the behavior under test, and touching .feature-prompted-model-overlay under ~/.claude/skills/gstack/ trips Claude Code's sensitive-file permission prompt — the run stalls on that dialog (classified outcome=asked) and the scope gate never renders. Dev machines never reproduce it because the operator's markers exist. Seed ~/.gstack one-time markers (.activated, .first-loop-tip-shown, .telemetry-prompted, .proactive-prompted, .completeness-intro-seen, .plan-tune-nudge-shown) and both .feature-prompted-* markers (via the gstack root symlink into the checkout) in the PTY-smoke registration step, so no first-run prompt can preempt the assertion under test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: re-version release as v1.67.1.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: restore main's dependency manifest clobbered by the merge resolution The v1.67.0.0 merge resolved the package.json conflict wholesale --ours, which kept this branch's version stamp but erased main's dependency work (playwright 1.58->1.62 + its patchedDependencies entry, transformers 4.1->4.2, cross-spawn added, puppeteer-core removed — which is also why main dropped the basic-ftp pin test: the pinned package left the tree with it — marked/socks bumps, adm-zip override) while bun.lock auto-merged to main's side. Every CI job that runs `bun install --frozen-lockfile` failed on the mismatch (check-freshness, quality, free-tests, gate, windows x2). Take main's package.json + bun.lock verbatim, re-stamp the version through gstack-version-bump (1.67.1.0). bun.lock is now byte-identical to main's; frozen install verified locally; full free suite green for the branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
251 lines
10 KiB
JavaScript
251 lines
10 KiB
JavaScript
/**
|
|
* Bun API polyfill for Node.js — Windows compatibility layer.
|
|
*
|
|
* On Windows, Bun can't launch or connect to Playwright's Chromium
|
|
* (oven-sh/bun#4253, #9911). The browse server falls back to running
|
|
* under Node.js with this polyfill providing Bun API equivalents.
|
|
*
|
|
* Loaded via --require before the transpiled server bundle.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const http = require('http');
|
|
const { spawnSync: nodeSpawnSync, spawn: nodeSpawn } = require('child_process');
|
|
// Node's spawn on Windows without shell:true only matches an EXACT
|
|
// executable name — no PATHEXT resolution the way a real shell (or
|
|
// Bun.spawn, which this file exists to polyfill) does. A bare command
|
|
// name like 'bun' (no .exe/.cmd) then fails ENOENT even though `bun`
|
|
// works fine typed at a prompt (confirmed live in #2461: this is what
|
|
// produced "[browse] FATAL uncaught exception: spawn bun ENOENT" from
|
|
// terminal-agent-control.ts's respawn path, once daemon output was
|
|
// actually being captured to a file instead of silently discarded).
|
|
//
|
|
// Two things this is NOT fixed with, both tried and rejected in #2461:
|
|
//
|
|
// 1. shell:true + array args. This file is also reached (via server.ts →
|
|
// write-commands.ts/meta-commands.ts → cookie-import-browser.ts/
|
|
// browser-skill-commands.ts) by calls that pass genuinely variable
|
|
// content — browser-skill-commands.ts spreads `...opts.skillArgs`,
|
|
// sourced from `$B skill run <name> --arg k=v`'s passthrough CLI args,
|
|
// into the spawned argv. shell:true on Windows routes through cmd.exe,
|
|
// and Node's own array-arg handling for that combination does NOT
|
|
// neutralize cmd.exe metacharacters (& | ^ % < >) — verified in #2461 by
|
|
// directly spawning a resolved .cmd path with an arg containing
|
|
// `& echo INJECTED > proof.txt`: the file was created. Hand-rolled
|
|
// double-quote-only escaping doesn't close that either.
|
|
//
|
|
// 2. Resolve the .exe/.cmd path ourselves and spawn it with NO shell.
|
|
// Works for .exe targets, but Node refuses (EINVAL) to spawn a
|
|
// .cmd/.bat file without shell:true — deliberately, as part of Node's
|
|
// CVE-2024-27980 fix for implicit unsafe .cmd execution. bun's own
|
|
// Windows install (npm global) is exactly a .cmd shim, so this path is
|
|
// not optional to support.
|
|
//
|
|
// cross-spawn (previously a transitive dep, now direct) is the established
|
|
// library for precisely this problem: PATHEXT resolution AND correct
|
|
// Windows/cmd.exe argument escaping together. #2461 verified the injection
|
|
// payload above reaches the child as a single literal argument while
|
|
// normal resolution (`bun --version`) still works.
|
|
const crossSpawn = require('cross-spawn');
|
|
|
|
globalThis.Bun = {
|
|
serve(options) {
|
|
const { port, hostname = '127.0.0.1', fetch } = options;
|
|
|
|
const server = http.createServer(async (nodeReq, nodeRes) => {
|
|
try {
|
|
const url = `http://${hostname}:${port}${nodeReq.url}`;
|
|
const headers = new Headers();
|
|
for (const [key, val] of Object.entries(nodeReq.headers)) {
|
|
if (val) headers.set(key, Array.isArray(val) ? val[0] : val);
|
|
}
|
|
|
|
let body = null;
|
|
if (nodeReq.method !== 'GET' && nodeReq.method !== 'HEAD') {
|
|
body = await new Promise((resolve) => {
|
|
const chunks = [];
|
|
nodeReq.on('data', (chunk) => chunks.push(chunk));
|
|
nodeReq.on('end', () => resolve(Buffer.concat(chunks)));
|
|
});
|
|
}
|
|
|
|
const webReq = new Request(url, {
|
|
method: nodeReq.method,
|
|
headers,
|
|
body,
|
|
});
|
|
|
|
const webRes = await fetch(webReq);
|
|
|
|
nodeRes.statusCode = webRes.status;
|
|
webRes.headers.forEach((val, key) => {
|
|
nodeRes.setHeader(key, val);
|
|
});
|
|
|
|
const resBody = await webRes.arrayBuffer();
|
|
nodeRes.end(Buffer.from(resBody));
|
|
} catch (err) {
|
|
nodeRes.statusCode = 500;
|
|
nodeRes.end(JSON.stringify({ error: err.message }));
|
|
}
|
|
});
|
|
|
|
server.listen(port, hostname);
|
|
|
|
return {
|
|
stop() { server.close(); },
|
|
port,
|
|
hostname,
|
|
};
|
|
},
|
|
|
|
spawnSync(cmd, options = {}) {
|
|
const [command, ...args] = cmd;
|
|
const spawnSyncFn = process.platform === 'win32' ? crossSpawn.sync : nodeSpawnSync;
|
|
const result = spawnSyncFn(command, args, {
|
|
stdio: [
|
|
options.stdin || 'pipe',
|
|
options.stdout === 'pipe' ? 'pipe' : 'ignore',
|
|
options.stderr === 'pipe' ? 'pipe' : 'ignore',
|
|
],
|
|
timeout: options.timeout,
|
|
env: options.env,
|
|
cwd: options.cwd,
|
|
// Node defaults windowsHide to false; Bun.spawn hides the console
|
|
// window. Without this the shim silently inverts the behavior on the
|
|
// one platform it exists to serve — every console child pops a window.
|
|
// Forwarded (not hardcoded) so an explicit windowsHide:false survives.
|
|
windowsHide: options.windowsHide !== false,
|
|
});
|
|
|
|
return {
|
|
exitCode: result.status,
|
|
stdout: result.stdout || Buffer.from(''),
|
|
stderr: result.stderr || Buffer.from(''),
|
|
};
|
|
},
|
|
|
|
spawn(cmd, options = {}) {
|
|
const [command, ...args] = cmd;
|
|
const stdio = options.stdio || ['pipe', 'pipe', 'pipe'];
|
|
const spawnFn = process.platform === 'win32' ? crossSpawn : nodeSpawn;
|
|
const proc = spawnFn(command, args, {
|
|
stdio,
|
|
env: options.env,
|
|
cwd: options.cwd,
|
|
// stdio:'ignore' silences a child's output but does not suppress its
|
|
// console window on Windows. The terminal-agent respawn (server.ts
|
|
// watchdog, 60s ticker) popped a visible bun.exe window on every
|
|
// respawn until this was forwarded. Forwarded, not hardcoded, so an
|
|
// explicit windowsHide:false survives.
|
|
windowsHide: options.windowsHide !== false,
|
|
});
|
|
|
|
// Drain stdout/stderr eagerly into in-memory buffers. Bun's spawn buffers
|
|
// these for the consumer; Node's Readables are pull-based, so if the caller
|
|
// awaits `proc.exited` before reading, anything past the OS pipe buffer
|
|
// (~16-64 KB) back-pressures the child until it blocks in write() and
|
|
// `exit` never fires. Eager draining keeps the pipes flowing regardless
|
|
// of read order; replay below is via fresh Web ReadableStreams.
|
|
//
|
|
// Cap the buffer so a runaway child can't OOM the server. 16 MB is
|
|
// generous: DPAPI outputs are tiny, tasklist is <1 KB, and the
|
|
// browser-skill consumer has its own 1 MB readCapped. Once the cap is
|
|
// reached we keep draining the pipe (so the child never blocks) but
|
|
// discard further bytes. Override via GSTACK_SPAWN_MAX_BUFFER (bytes).
|
|
const MAX_BUFFER = Math.max(
|
|
0,
|
|
parseInt(process.env.GSTACK_SPAWN_MAX_BUFFER || '', 10) || 16 * 1024 * 1024,
|
|
);
|
|
const drain = (stream) => {
|
|
if (!stream) return { done: Promise.resolve(), chunks: [], truncated: false };
|
|
const state = { chunks: [], bytes: 0, truncated: false };
|
|
const done = new Promise((resolve) => {
|
|
stream.on('data', (chunk) => {
|
|
if (state.bytes >= MAX_BUFFER) { state.truncated = true; return; }
|
|
if (state.bytes + chunk.length <= MAX_BUFFER) {
|
|
state.chunks.push(chunk);
|
|
state.bytes += chunk.length;
|
|
} else {
|
|
const remaining = MAX_BUFFER - state.bytes;
|
|
state.chunks.push(chunk.subarray(0, remaining));
|
|
state.bytes = MAX_BUFFER;
|
|
state.truncated = true;
|
|
}
|
|
});
|
|
// Any terminal event resolves: 'end' on normal close, 'error' on a
|
|
// stream-level error, 'close' as the belt-and-suspenders for spawn
|
|
// failures where Node fires 'close' but neither 'end' nor 'error'.
|
|
stream.once('end', resolve);
|
|
stream.once('error', resolve);
|
|
stream.once('close', resolve);
|
|
});
|
|
return { done, chunks: state.chunks };
|
|
};
|
|
const stdoutDrain = drain(proc.stdout);
|
|
const stderrDrain = drain(proc.stderr);
|
|
|
|
// Bun's spawn exposes `proc.exited` as a Promise resolving to the exit
|
|
// code; several call sites — DPAPI decryption, isBrowserRunning,
|
|
// browser-skill-commands — `await proc.exited` directly or via
|
|
// Promise.race with a timeout. Without this, those awaits resolve to
|
|
// `undefined` immediately and the operation looks like a silent failure.
|
|
// Resolve only after both pipes have finished draining so consumers that
|
|
// read stdout AFTER awaiting exit see the full output, not a partial buffer.
|
|
const exited = new Promise((resolveExited) => {
|
|
let exitStatus;
|
|
proc.once('exit', (code, signal) => {
|
|
// Match Bun: exit code on normal exit; 128 + signal number on signal;
|
|
// 0 if neither was reported.
|
|
if (code !== null) exitStatus = code;
|
|
else if (signal) exitStatus = 128 + (require('os').constants.signals[signal] || 0);
|
|
else exitStatus = 0;
|
|
});
|
|
proc.once('error', () => {
|
|
if (exitStatus === undefined) exitStatus = 1;
|
|
});
|
|
// Wait for either 'exit' (normal child lifecycle) or 'error' (spawn
|
|
// failure — Node fires error without exit when the binary is missing).
|
|
// Either path resolves the lifecycle promise; without listening to both
|
|
// a spawn error hangs `await proc.exited` until the consumer's own
|
|
// timeout fires.
|
|
const lifecycle = new Promise((r) => {
|
|
proc.once('exit', r);
|
|
proc.once('error', r);
|
|
});
|
|
Promise.all([lifecycle, stdoutDrain.done, stderrDrain.done])
|
|
.then(() => resolveExited(exitStatus !== undefined ? exitStatus : 0));
|
|
});
|
|
|
|
// Replay buffered output as a fresh Web ReadableStream. `start()` awaits
|
|
// the drain before enqueueing so `new Response(proc.stdout).text()` yields
|
|
// the complete output regardless of whether the consumer reads before or
|
|
// after awaiting `proc.exited`. Stream is single-shot (locked after one
|
|
// read), matching Bun's behavior.
|
|
const replay = (d) => new ReadableStream({
|
|
async start(controller) {
|
|
await d.done;
|
|
for (const chunk of d.chunks) {
|
|
controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
|
|
}
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
return {
|
|
pid: proc.pid,
|
|
stdout: replay(stdoutDrain),
|
|
stderr: replay(stderrDrain),
|
|
stdin: proc.stdin,
|
|
exited,
|
|
unref() { proc.unref(); },
|
|
kill(signal) { proc.kill(signal); },
|
|
};
|
|
},
|
|
|
|
sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
},
|
|
};
|