Merge origin/main (v1.67.0.0) — reconcile convergent iOS Release-guard fixes

main's v1.67.0.0 independently landed the DebugBridgeTouch Release compile-out
with a stronger shape (`#if !defined(DEBUG)` short-circuit before the platform
gate, measured via nm -j on a real Release binary) than this branch's
`#if TARGET_OS_IOS && DEBUG`. Resolution: take main's templates/fixtures, keep
this branch's free-tier static tripwire and adapt it to pin main's shape
(short-circuit present, ordered before the platform branch, cSettings DEBUG
define intact, no bare platform-only gate). VERSION/package.json stay 1.67.1.0;
CHANGELOG keeps both entries with 1.67.1.0 on top, its iOS claims reworded to
the residual contribution (the tripwire, not the compile-out itself).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 20:18:43 -07:00
co-authored by Claude Fable 5
246 changed files with 13732 additions and 2289 deletions
+92 -2
View File
@@ -6,12 +6,102 @@
*/
import { spawnSync } from "child_process";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
import { homedir } from "os";
import { basename, join } from "path";
/** Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`). */
/** Keep the slug inside the [a-zA-Z0-9._-] alphabet gstack-slug promises (`tr -cd`). */
function sanitizeSlug(s: string): string {
return s.replace(/[^a-zA-Z0-9._-]/g, "");
}
/**
* A Windows path in the MSYS form git-bash's `pwd` reports:
* `C:\Users\j\foo` → `/c/Users/j/foo`. gstack-slug keys its cache on THAT form
* (`tr '/' '_'`), so a native lookup must reproduce it exactly or it misses the very
* entry gstack-slug wrote and silently re-derives instead of staying consistent.
* Exported for the cache-key test; non-Windows paths pass through unchanged.
*/
export function toMsysPath(p: string): string {
const drive = p.match(/^([A-Za-z]):[\\/]/);
const body = (drive ? p.slice(2) : p).replace(/\\/g, "/");
return drive ? `/${drive[1].toLowerCase()}${body}` : body;
}
/**
* Native port of bin/gstack-slug's resolution order, used when that script cannot be
* spawned (see resolveSlug). Same three steps, same alphabet, same cache file — so
* this and the shell path always agree. They must: the bins WRITE using this, while
* the Context Recovery preamble READS using the script.
*/
export function slugFromEnvironment(gstackHome?: string, cwd: string = process.cwd()): string {
const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack");
const cacheDir = join(home, "slug-cache");
const cacheFile = join(cacheDir, toMsysPath(cwd).replace(/\//g, "_"));
let slug = "";
// 1. cached slug wins (guarantees consistency across sessions)
if (existsSync(cacheFile)) {
try {
slug = sanitizeSlug(readFileSync(cacheFile, "utf-8").trim());
} catch {
slug = "";
}
}
// 2. else derive from the git remote: [:/]<owner>/<repo>[.git] → owner-repo
if (!slug) {
const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8", cwd });
const m = (r.stdout || "").trim().match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
if (m) slug = sanitizeSlug(m[1].replace(/\//g, "-"));
}
// 3. else the directory name
if (!slug) slug = sanitizeSlug(basename(cwd));
if (!slug) return "unknown";
// 4. cache it, as gstack-slug does — atomic, and failures stay silent (`|| true`)
try {
mkdirSync(cacheDir, { recursive: true });
const tmp = `${cacheFile}.tmp.${process.pid}`;
writeFileSync(tmp, slug, "utf-8");
renameSync(tmp, cacheFile);
} catch {
// best-effort cache; a miss only costs a re-derive on the next call
}
return slug;
}
/** Windows cannot exec an extensionless `#!/usr/bin/env bash` script (no shebang, no
* PATHEXT match for an explicit path), so gstack-slug spawns ENOENT there. */
export const NEEDS_NATIVE_SLUG_ON_WINDOWS = process.platform === "win32";
/**
* Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`).
*
* On Windows that spawn fails ENOENT (see NEEDS_NATIVE_SLUG_ON_WINDOWS) and `r.stdout`
* is undefined — the same class of hazard as the gbrain shim spawns in lib/gbrain-exec.ts
* (#1731). Returning the literal "unknown" filed every decision under
* ~/.gstack/projects/unknown/ — one bucket shared by every project on the machine —
* while the bash-side Context Recovery preamble resolved the real slug, found no
* decisions.active.json there, and skipped through a bare `if [ -f … ]` with no else.
*
* Nothing failed, for ten days: BOTH decision bins (log and search) missed identically,
* so writes and searches stayed consistent with each other, and the only component that
* resolved correctly was silent by design.
*
* `shell: true` is NOT the fix here, unlike #1731: cmd.exe cannot run a bash script
* either. Nor is re-spawning through `bash` — on Windows that frequently resolves to
* WSL, whose $HOME and /mnt/c paths yield a different slug AND a different cache
* directory, trading one split store for another.
*
* POSIX behaviour is unchanged: the fallback is win32-only, where the previous result
* was unconditionally wrong and so has nothing to regress.
*/
export function resolveSlug(slugBinPath: string): string {
const r = spawnSync(slugBinPath, { encoding: "utf-8" });
const m = (r.stdout || "").match(/^SLUG=(.+)$/m);
return m ? m[1].trim() : "unknown";
if (m) return m[1].trim();
if (NEEDS_NATIVE_SLUG_ON_WINDOWS) return slugFromEnvironment();
return "unknown";
}
/** Current git branch, or undefined on detached HEAD / outside a repo. */
+118 -15
View File
@@ -21,10 +21,12 @@
* spawn. This is the central bug the helper exists to prevent
* regressing on.
*
* 3. **`GBRAIN_HOME` honored consistently.** Other gstack helpers
* (`detectEngineTier`) already honor `GBRAIN_HOME`. `buildGbrainEnv`
* reads from `${GBRAIN_HOME:-$HOME/.gbrain}/config.json` so all
* gstack-side gbrain calls agree on which config file matters.
* 3. **`GBRAIN_HOME` honored consistently — with gbrain's own semantics
* (#2521).** gbrain's configDir() treats `GBRAIN_HOME` as a PARENT
* directory and always appends `.gbrain` itself (GBRAIN_HOME=/tmp/x
* → /tmp/x/.gbrain/config.json). Every gstack-side read goes through
* `gbrainConfigDir()` below so gstack and gbrain agree on which
* config file matters.
*
* **Escape hatch:** `GSTACK_RESPECT_ENV_DATABASE_URL=1` returns the
* caller's env unchanged. Use only when the brain intentionally lives in
@@ -75,8 +77,21 @@ export function isTransactionModePooler(url: string): boolean {
}
/**
* Build an env dict with DATABASE_URL seeded from
* `${GBRAIN_HOME:-$HOME/.gbrain}/config.json`. Returns the base env
* gbrain's config directory, matching gbrain's own configDir() contract
* (#2521): `GBRAIN_HOME` is a PARENT directory — gbrain always appends
* `.gbrain` itself, so GBRAIN_HOME=/tmp/x reads /tmp/x/.gbrain/config.json.
* Unset → ~/.gbrain. Every gstack-side gbrain-config read MUST resolve
* through this helper, or gstack classifies engine status from a file
* gbrain never reads.
*/
export function gbrainConfigDir(env: NodeJS.ProcessEnv = process.env): string {
if (env.GBRAIN_HOME) return join(env.GBRAIN_HOME, ".gbrain");
return join(env.HOME || homedir(), ".gbrain");
}
/**
* Build an env dict with DATABASE_URL seeded from gbrain's config.json
* (resolved via `gbrainConfigDir`). Returns the base env
* unchanged when:
* - `GSTACK_RESPECT_ENV_DATABASE_URL=1` (intentional opt-out),
* - the config file is missing or unparseable,
@@ -98,9 +113,7 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process
const out: NodeJS.ProcessEnv = { ...baseEnv };
if (baseEnv.GSTACK_RESPECT_ENV_DATABASE_URL === "1") return out;
const homeBase = baseEnv.HOME || homedir();
const gbrainHome = baseEnv.GBRAIN_HOME || join(homeBase, ".gbrain");
const configPath = join(gbrainHome, "config.json");
const configPath = join(gbrainConfigDir(baseEnv), "config.json");
if (!existsSync(configPath)) return out;
let cfg: GbrainConfig = {};
@@ -136,6 +149,93 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process
*/
export const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32";
/** Where Git for Windows puts bash, most-specific first. */
const WINDOWS_BASH_CANDIDATES = [
"C:\\Program Files\\Git\\bin\\bash.exe",
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
"C:\\Program Files (x86)\\Git\\bin\\bash.exe",
];
export interface ScriptInvocation {
cmd: string;
argv: string[];
/** Always false: we resolve the interpreter ourselves rather than via cmd.exe. */
shell: false;
}
/**
* How to invoke a **bash shebang script** (`gstack-brain-sync`) on this platform.
*
* POSIX execs it directly — the shebang does the work. Windows cannot, and
* `shell: true` does NOT rescue it: that routes through cmd.exe, which resolves
* `.cmd`/`.bat` via PATHEXT but has no concept of a shebang, so an
* extension-less bash script comes back as *"is not recognized as an internal
* or external command"*. This is why #1731's `shell: NEEDS_SHELL_ON_WINDOWS`
* fix genuinely cured the `gbrain.cmd` shim while leaving the brain-sync stage
* failing on **every** run on Windows. The two cases look identical and are not:
* a `.cmd` shim needs a shell, a shebang script needs an interpreter.
*
* The consequence was quiet rather than loud. `artifacts_sync_mode` defaults to
* pushing curated artifacts to git, so a Windows user's learnings accumulated in
* `~/.gstack` and were never committed, while `/sync-gbrain` printed one red
* line among four green ones.
*
* Git for Windows' bash is preferred over a bare `bash` on PATH because
* WindowsApps ships a `bash.exe` that is the WSL launcher; if it wins PATH
* order it interprets `C:\...` as a Linux path and the script never sees the
* repo. `GSTACK_BASH` overrides everything for unusual installs.
*
* Returns `null` when no bash can be found, so the caller can say so plainly
* instead of surfacing a spawn error nobody can act on.
*/
export function bashScriptInvocation(
scriptPath: string,
args: string[],
opts: { platform?: string; exists?: (p: string) => boolean; env?: NodeJS.ProcessEnv } = {},
): ScriptInvocation | null {
const platform = opts.platform ?? process.platform;
if (platform !== "win32") return { cmd: scriptPath, argv: args, shell: false };
const exists = opts.exists ?? existsSync;
const env = opts.env ?? process.env;
const override = env.GSTACK_BASH?.trim();
const candidates = [...(override ? [override] : []), ...WINDOWS_BASH_CANDIDATES];
const bash = candidates.find((p) => exists(p));
if (!bash) return null;
// Forward slashes: bash treats backslashes as escapes, so a Windows path
// passed verbatim loses its separators.
return { cmd: bash, argv: [scriptPath.replace(/\\/g, "/"), ...args], shell: false };
}
/**
* Quote one argument for cmd.exe's re-parse (#2471). With `shell: true` on
* Windows, Node/Bun JOIN the argv into a single cmd.exe string WITHOUT
* quoting, so any argument containing a space — the default
* `C:\Users\First Last\repo` home layout — splits into two arguments and the
* gbrain call silently targets the wrong path. Pass-through for the safe
* charset; everything else is double-quoted with embedded quotes doubled
* (cmd.exe's escape). POSIX callers never see this (shell is false there).
*/
export function windowsShellQuote(arg: string): string {
if (arg !== "" && /^[A-Za-z0-9_\-.:\\/=,@+]+$/.test(arg)) return arg;
return '"' + arg.replace(/"/g, '""') + '"';
}
/**
* The single seam for building a gbrain CLI invocation (#2471). Every
* spawnSync/execFileSync of the `gbrain` shim must construct its
* (cmd, argv, shell) triple here so the Windows quoting fix lives in exactly
* one place — a direct spawn of the literal "gbrain" string with a shell
* flag reopens the space-in-path split this exists to close.
*/
export function gbrainInvocation(args: string[]): { cmd: string; argv: string[]; shell: boolean } {
return NEEDS_SHELL_ON_WINDOWS
? { cmd: "gbrain", argv: args.map(windowsShellQuote), shell: true }
: { cmd: "gbrain", argv: args, shell: false };
}
export interface SpawnGbrainOptions {
/** Timeout in milliseconds. Defaults to 30s. */
timeout?: number;
@@ -159,13 +259,14 @@ export interface SpawnGbrainOptions {
* `stderr` exactly as they would with `spawnSync` directly.
*/
export function spawnGbrain(args: string[], opts: SpawnGbrainOptions = {}): SpawnSyncReturns<string> {
return spawnSync("gbrain", args, {
const inv = gbrainInvocation(args);
return spawnSync(inv.cmd, inv.argv, {
encoding: "utf-8",
timeout: opts.timeout ?? 30_000,
cwd: opts.cwd,
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }),
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
});
}
@@ -194,11 +295,12 @@ export function spawnGbrainAsync(
args: string[],
opts: { stdio?: SpawnOptions["stdio"]; cwd?: string; baseEnv?: NodeJS.ProcessEnv } = {},
): ChildProcess {
return spawn("gbrain", args, {
const inv = gbrainInvocation(args);
return spawn(inv.cmd, inv.argv, {
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
cwd: opts.cwd,
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: false }),
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
});
}
@@ -207,12 +309,13 @@ export function spawnGbrainAsync(
* for callers that want to surface gbrain's stderr as the error message.
*/
export function execGbrainText(args: string[], opts: SpawnGbrainOptions = {}): string {
return execFileSync("gbrain", args, {
const inv = gbrainInvocation(args);
return execFileSync(inv.cmd, inv.argv, {
encoding: "utf-8",
timeout: opts.timeout ?? 30_000,
cwd: opts.cwd,
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }),
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
});
}
+3 -1
View File
@@ -36,7 +36,9 @@ import { execGbrainJson, execGbrainText, NEEDS_SHELL_ON_WINDOWS } from "./gbrain
import { parseSourcesList, type GbrainSourceRow } from "./gbrain-sources";
export function gbrainHome(env: NodeJS.ProcessEnv = process.env): string {
return env.GBRAIN_HOME || join(homedir(), ".gbrain");
// #2521: GBRAIN_HOME is a PARENT dir per gbrain's configDir() contract —
// gbrain appends `.gbrain` itself, so gstack must too.
return env.GBRAIN_HOME ? join(env.GBRAIN_HOME, ".gbrain") : join(homedir(), ".gbrain");
}
/**
+134 -34
View File
@@ -24,12 +24,14 @@
* Timeout → probe exceeded GSTACK_GBRAIN_PROBE_TIMEOUT_MS (default 15s) with no
* recognized error — engine is likely healthy but slow (e.g. a cold
* pooler connection, #1964). Consumers treat this as usable.
* Thin-client → config carries gbrain's remote_mcp marker (#2051): NO local
* engine by design; queries go to a remote-HTTP MCP brain. Usable
* for brain-aware prose gates; sync stages that need a LOCAL engine
* (code/memory/dream) skip. Remote reachability is verified at USE
* time (gbrain calls degrade gracefully), never by a classifier
* network probe — that's the #1964 pathology.
* Thin-client → config carries gbrain's remote_mcp marker (#2051), OR the
* agent host's MCP registration is remote-HTTP-only (#2520 — bearer
* installs via `gbrain connect --token` never get the marker): NO
* local engine by design; queries go to a remote-HTTP MCP brain.
* Usable for brain-aware prose gates; sync stages that need a LOCAL
* engine (code/memory/dream) skip. Remote reachability is verified
* at USE time (gbrain calls degrade gracefully), never by a
* classifier network probe — that's the #1964 pathology.
* Ok → DB reachable, sources list returned valid JSON.
*/
@@ -46,7 +48,7 @@ import {
import { atomicWriteSync } from "./fs-atomic";
import { homedir } from "os";
import { dirname, join } from "path";
import { buildGbrainEnv, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
import { buildGbrainEnv, gbrainConfigDir, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
export type LocalEngineStatus =
| "ok"
@@ -120,11 +122,84 @@ export function cacheFilePath(): string {
);
}
/** Honors GBRAIN_HOME (codex D11) — same resolution as buildGbrainEnv. */
/**
* Honors GBRAIN_HOME (codex D11) with gbrain's own configDir() semantics
* (#2521): GBRAIN_HOME is a parent dir, `.gbrain` is appended. Same
* resolution as buildGbrainEnv — both route through gbrainConfigDir.
*/
function gbrainConfigPath(env?: NodeJS.ProcessEnv): string {
const e = env ?? process.env;
const gbrainHome = e.GBRAIN_HOME || join(userHome(e), ".gbrain");
return join(gbrainHome, "config.json");
return join(gbrainConfigDir(e), "config.json");
}
/**
* Bearer-token thin-client evidence (#2520). `gbrain connect <url> --token`
* registers a remote-HTTP MCP server with the agent host but never writes
* gbrain's remote_mcp marker into config.json — that marker is OAuth-only,
* written by `gbrain init --mcp-only`. So the config-file marker check misses
* bearer installs entirely: they fall through to the local probe, which fails
* against the dead-or-absent local engine and lands on missing-config /
* broken-db / broken-config / engine-locked, silently suppressing brain
* blocks for a fully-working remote brain.
*
* Evidence read: ~/.claude.json MCP registrations — user scope AND project
* scope (project-scoped registrations are otherwise invisible, #2499).
* File-read only: no subprocess, no network (a classifier network probe is
* the #1964 pathology). Returns true only when a gbrain registration is
* remote-HTTP AND no gbrain registration is local-stdio — a local-stdio
* entry means the user runs a local engine (possibly alongside a remote one,
* e.g. federation), and local-engine statuses like engine-locked must keep
* their precise meaning there.
*/
export function hasRemoteOnlyGbrainMcp(env?: NodeJS.ProcessEnv): boolean {
interface McpEntry {
type?: string;
transport?: string;
command?: string;
url?: string;
}
let cj: unknown;
try {
cj = JSON.parse(readFileSync(join(userHome(env), ".claude.json"), "utf-8"));
} catch {
return false;
}
// Same classification rules as gstack-gbrain-detect's detectMcpMode tier 3,
// including the #2051 name generalization (gbrain, gbrain-remote, gbrain_work).
const classify = (entry: McpEntry): "remote" | "local" | null => {
const mtype = entry.type || entry.transport || "";
if (mtype === "url" || mtype === "http" || mtype === "sse") return "remote";
if (mtype === "stdio") return "local";
if (entry.url) return "remote";
if (entry.command) return "local";
return null;
};
let sawRemote = false;
let sawLocal = false;
const scan = (servers: unknown): void => {
if (!servers || typeof servers !== "object") return;
for (const [name, entry] of Object.entries(servers as Record<string, McpEntry>)) {
if (!entry || typeof entry !== "object") continue;
const isGbrainName = /^gbrain([-_][\w-]*)?$/.test(name);
const cmdMentionsGbrain =
typeof entry.command === "string" && /\bgbrain\b/.test(entry.command);
if (!isGbrainName && !cmdMentionsGbrain) continue;
const c = classify(entry);
if (c === "remote") sawRemote = true;
if (c === "local") sawLocal = true;
}
};
const root = cj as {
mcpServers?: unknown;
projects?: Record<string, { mcpServers?: unknown }>;
} | null;
scan(root?.mcpServers);
if (root?.projects && typeof root.projects === "object") {
for (const proj of Object.values(root.projects)) {
if (proj && typeof proj === "object") scan(proj.mcpServers);
}
}
return sawRemote && !sawLocal;
}
function configuredEngine(env?: NodeJS.ProcessEnv): "pglite" | "postgres" | null {
@@ -146,6 +221,10 @@ function hashPath(p: string): string {
* call share one fork-exec (~200ms saved per skill preamble).
*/
const _gbrainBinCache = new Map<string, string | null>();
// On Windows the shim is `gbrain.cmd` → `bun run cli.ts`; a cold spawn can
// exceed 2s, and a false negative here poisons the 60s status cache with
// "no-cli". Give the shim headroom; POSIX keeps the tight timeout.
const VERSION_PROBE_TIMEOUT_MS = NEEDS_SHELL_ON_WINDOWS ? 10_000 : 2_000;
export function resolveGbrainBin(env?: NodeJS.ProcessEnv): string | null {
const e = env ?? process.env;
const key = e.PATH || "";
@@ -154,7 +233,7 @@ export function resolveGbrainBin(env?: NodeJS.ProcessEnv): string | null {
try {
execFileSync("gbrain", ["--version"], {
encoding: "utf-8",
timeout: 2_000,
timeout: VERSION_PROBE_TIMEOUT_MS,
stdio: ["ignore", "ignore", "ignore"],
env: e,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
@@ -177,7 +256,7 @@ export function readGbrainVersion(env?: NodeJS.ProcessEnv): string {
try {
const out = execFileSync("gbrain", ["--version"], {
encoding: "utf-8",
timeout: 2_000,
timeout: VERSION_PROBE_TIMEOUT_MS,
stdio: ["ignore", "pipe", "ignore"],
env: e,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
@@ -271,8 +350,12 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
const gbrainBin = resolveGbrainBin(env);
if (!gbrainBin) return "no-cli";
// 2. Config file present?
if (!existsSync(gbrainConfigPath(env))) return "missing-config";
// 2. Config file present? A bearer thin client (#2520) may never have run
// a local init, so config.json can be absent while the remote-HTTP MCP
// registration IS the user's brain.
if (!existsSync(gbrainConfigPath(env))) {
return hasRemoteOnlyGbrainMcp(env) ? "thin-client" : "missing-config";
}
// 2.5 Thin client? gbrain's own marker (mirrors gbrain isThinClient():
// truthy remote_mcp in config). A thin client has NO local engine — gbrain
@@ -329,28 +412,45 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
// couldn't read — gbrain's dispatch guard says e.g. "`gbrain sources` is
// not routable ... (thin-client of <url>)"), then the more specific
// DB-unreachable signal.
if (/thin[- ]client/i.test(stderr)) return "thin-client";
if (stderr.includes("Cannot connect to database")) return "broken-db";
if (stderr.includes("config.json")) return "broken-config";
const raw = ((): LocalEngineStatus => {
if (/thin[- ]client/i.test(stderr)) return "thin-client";
if (stderr.includes("Cannot connect to database")) return "broken-db";
if (stderr.includes("config.json")) return "broken-config";
// PGLite is single-process. A long-lived `gbrain serve` can own the
// embedded database, causing the CLI to finish with its own exit 124 and
// "connect timed out" message. This is neither our watchdog timeout nor
// evidence that the valid config is malformed (#2194).
if (stderr.includes("connect timed out") || e.status === 124) {
return configuredEngine(env) === "pglite" ? "engine-locked" : "broken-db";
// PGLite is single-process. A long-lived `gbrain serve` can own the
// embedded database, causing the CLI to finish with its own exit 124 and
// "connect timed out" message. This is neither our watchdog timeout nor
// evidence that the valid config is malformed (#2194).
if (stderr.includes("connect timed out") || e.status === 124) {
return configuredEngine(env) === "pglite" ? "engine-locked" : "broken-db";
}
// Probe killed by the timeout with no recognized error: the engine is
// most likely healthy but slow (cold pooler connections measured at
// 6.9-10.7s in #1964). Don't tell the user their config is malformed.
if (e.killed === true || e.signal === "SIGTERM" || e.code === "ETIMEDOUT") {
return "timeout";
}
// Defensive default per codex #8: unrecognized failures classify as
// broken-config so the user sees the raw stderr surfaced upstream.
return "broken-config";
})();
// #2520 bearer-token fallback: the local probe failed, but the user's
// only gbrain MCP registration is remote-HTTP — the dead-or-locked local
// engine is not their brain (typical shape: a leftover local config plus
// `gbrain connect --token`). Reclassify as thin-client so brain blocks
// stay rendered and sync's local stages skip with the accurate "nothing
// to do locally" message. "timeout" is deliberately excluded: it already
// counts as usable and may be a genuinely healthy slow LOCAL engine.
if (
(raw === "broken-db" || raw === "broken-config" || raw === "engine-locked") &&
hasRemoteOnlyGbrainMcp(env)
) {
return "thin-client";
}
// Probe killed by the timeout with no recognized error: the engine is
// most likely healthy but slow (cold pooler connections measured at
// 6.9-10.7s in #1964). Don't tell the user their config is malformed.
if (e.killed === true || e.signal === "SIGTERM" || e.code === "ETIMEDOUT") {
return "timeout";
}
// Defensive default per codex #8: unrecognized failures classify as
// broken-config so the user sees the raw stderr surfaced upstream.
return "broken-config";
return raw;
}
}
+19 -18
View File
@@ -12,7 +12,7 @@
import { execFileSync, spawnSync } from "child_process";
import { realpathSync } from "fs";
import { withErrorContext } from "./gstack-memory-helpers";
import { execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
import { execGbrainJson, gbrainInvocation } from "./gbrain-exec";
import {
detectAutopilot,
decideSourceRemove,
@@ -117,12 +117,13 @@ function samePath(registered: string | undefined, requested: string): boolean {
export function probeSource(id: string, env?: NodeJS.ProcessEnv): SourceState {
let stdout: string;
try {
stdout = execFileSync("gbrain", ["sources", "list", "--json"], {
const inv = gbrainInvocation(["sources", "list", "--json"]);
stdout = execFileSync(inv.cmd, inv.argv, {
encoding: "utf-8",
timeout: 30_000,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
});
} catch (err) {
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
@@ -224,29 +225,28 @@ export async function ensureSourceRegistered(
throw new Error(`refusing drift re-register of ${id}: ${decision.reason}`);
}
const rm = spawnSync(
"gbrain",
["sources", "remove", id, "--yes", "--confirm-destructive", ...decision.extraArgs],
{
encoding: "utf-8",
timeout: 30_000,
env,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
},
);
const rmInv = gbrainInvocation(["sources", "remove", id, "--yes", "--confirm-destructive", ...decision.extraArgs]);
const rm = spawnSync(rmInv.cmd, rmInv.argv, {
encoding: "utf-8",
timeout: 30_000,
env,
shell: rmInv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
});
if (rm.status !== 0) {
throw new Error(`gbrain sources remove ${id} failed: ${rm.stderr || rm.stdout || `exit ${rm.status}`}`);
}
}
// Add.
// Add. `path` is a user repo path — the #2471 space-in-path victim; the
// invocation seam quotes it for cmd.exe's re-parse.
const addArgs = ["sources", "add", id, "--path", path];
if (federated) addArgs.push("--federated");
const add = spawnSync("gbrain", addArgs, {
const addInv = gbrainInvocation(addArgs);
const add = spawnSync(addInv.cmd, addInv.argv, {
encoding: "utf-8",
timeout: 30_000,
env,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
shell: addInv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
});
if (add.status !== 0) {
throw new Error(`gbrain sources add ${id} failed: ${add.stderr || add.stdout || `exit ${add.status}`}`);
@@ -267,12 +267,13 @@ export async function ensureSourceRegistered(
export function sourcePageCount(id: string, env?: NodeJS.ProcessEnv): number | null {
let stdout: string;
try {
stdout = execFileSync("gbrain", ["sources", "list", "--json"], {
const inv = gbrainInvocation(["sources", "list", "--json"]);
stdout = execFileSync(inv.cmd, inv.argv, {
encoding: "utf-8",
timeout: 30_000,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
});
} catch {
return null;
+5 -3
View File
@@ -19,6 +19,7 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "fs";
import { appendJsonl } from "./jsonl-store";
import { gbrainConfigDir } from "./gbrain-exec";
import { dirname, join } from "path";
import { execFileSync } from "child_process";
import { homedir } from "os";
@@ -256,12 +257,13 @@ export function detectEngineTier(): EngineDetect {
}
// Returns gbrain's config.json path, honoring GBRAIN_HOME env var with a
// fallback to ~/.gbrain. gbrain >=0.25 dropped the top-level `engine` field
// fallback to ~/.gbrain. Resolution matches gbrain's own configDir()
// contract (#2521): GBRAIN_HOME is a parent dir, `.gbrain` is appended.
// gbrain >=0.25 dropped the top-level `engine` field
// from doctor output, so this file is the only reliable source for engine
// detection on that version. See #1415.
function gbrainConfigPath(): string {
const root = process.env.GBRAIN_HOME || join(homedir(), ".gbrain");
return join(root, "config.json");
return join(gbrainConfigDir(process.env), "config.json");
}
// Best-effort JSONL append to ~/.gstack/.gbrain-errors.jsonl. Never throws.
+109 -6
View File
@@ -108,6 +108,39 @@ export function shannonEntropy(s: string): number {
return h;
}
// env.kv name-shape calibration: the regex's zero-or-more-prefix net matches
// ANY identifier ending in a credential suffix, so `cacheKey:`, `sortKey:`,
// `partitionKey:`, `hotkey:`, even `monkey:` with an 8+-char entropic value
// all hit a MEDIUM confirm prompt — a gate that cries wolf gets ignored.
// A matched name only counts when its shape is credential-semantic:
// (i) suffix separated from the prefix by _ / - / . (api_key, x-access-key,
// AUTH.TOKEN)
// (ii) the whole name IS the bare suffix (key:, token:)
// (iii) the name is ALL-CAPS env style (APIKEY=, MY_APIKEY=)
// (iv) a lowercase/camel compound whose prefix ends in a credential word
// (apiKey, authToken, clientSecret, stripeApiKey) — cacheKey/sortKey/
// monkey have no credential prefix and are rejected.
const ENV_KV_NAME =
/^[ \t]*(?:export[ \t]+)?["']?([A-Za-z0-9_.-]*?(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE))["']?[ \t]*[:=]/i;
const ENV_KV_SUFFIX =
/(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)$/i;
const ENV_KV_CRED_PREFIX =
/(api|auth|access|secret|private|app|client|server|master|admin|signing|encryption|session|csrf|jwt|oauth|bearer)$/i;
/** True when the full env.kv match starts with a credential-shaped name. */
export function isCredentialShapedEnvName(fullMatch: string): boolean {
const nameMatch = ENV_KV_NAME.exec(fullMatch);
if (!nameMatch) return false;
const name = nameMatch[1];
const suffixMatch = ENV_KV_SUFFIX.exec(name);
if (!suffixMatch) return false;
const prefix = name.slice(0, name.length - suffixMatch[1].length);
if (prefix === "") return true; // (ii) bare suffix
if (/[_.\-]$/.test(prefix)) return true; // (i) separator before suffix
if (!/[a-z]/.test(name)) return true; // (iii) ALL-CAPS env style
return ENV_KV_CRED_PREFIX.test(prefix); // (iv) credential-semantic compound
}
/** True when an IPv4 string is a public address (not RFC1918/loopback/etc). */
export function isPublicIPv4(ip: string): boolean {
const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
@@ -168,6 +201,58 @@ function looksLikeCompactTimestamp(span: string): boolean {
);
}
/** Context window for pairing a normalized parcel ID with its punctuated form. */
const PARCEL_CONTEXT_CHARS = 400;
/**
* County tax-map parcel ID (APN). Ohio's dominant form is NN-NNNNNNN.NNN, and
* some counties use a hyphen before the 3-4 digit suffix instead of a dot.
*/
const PARCEL_PUNCT_RE = /\b\d{2}-\d{4,8}[.\-]\d{3,4}\b/g;
/**
* A parcel ID reads as a national-format phone number to pii.phone.e164 — the
* same collision class as the digit-only UUID that `insideUuid` already guards.
* Land/title repos carry these by the hundred, so the false positives are not
* incidental: they arrive on every branch that touches title, and a guardrail
* that cries wolf on the domain's primary identifier trains people to wave the
* warning through, which is how a real HIGH finding eventually gets ignored.
*
* Deliberately narrow, in two tiers:
*
* 1. The DOTTED form is exempt on its own shape. No phone convention places a
* dot before a trailing 3-4 digit group after a 4-8 digit middle, so this
* cannot swallow a real number. The hyphen-only variants (22-0001-000) are
* NOT exempted by shape — those genuinely are phone-shaped.
*
* 2. A DIGITS-ONLY span is phone-shaped in isolation, so it earns the exemption
* only by evidence: it must be the exact digit-normalization of a punctuated
* parcel ID within the surrounding window. Fixtures and marts always carry
* the pair ({parcel_id: "12-3456789.000", norm: "123456789000"}), and a bare
* phone number has no such twin nearby — so this reads the document's own
* evidence rather than guessing from digits.
*/
export function looksLikeParcelId(span: string, match: RegExpExecArray): boolean {
if (/^\d{2}-\d{4,8}\.\d{3,4}$/.test(span)) return true;
if (!/^\d{10,14}$/.test(span)) return false;
const input = match.input ?? "";
const spanStartInMatch = match[1] !== undefined ? match[0].indexOf(match[1]) : 0;
const spanStart = match.index + Math.max(0, spanStartInMatch);
const spanEnd = spanStart + span.length;
const window = input.slice(
Math.max(0, spanStart - PARCEL_CONTEXT_CHARS),
spanEnd + PARCEL_CONTEXT_CHARS,
);
PARCEL_PUNCT_RE.lastIndex = 0;
let p: RegExpExecArray | null;
while ((p = PARCEL_PUNCT_RE.exec(window)) !== null) {
if (p[0].replace(/\D/g, "") === span) return true;
}
return false;
}
// ── Placeholder suppression (per-matched-span, NOT per-line) ─────────────────
/**
@@ -520,10 +605,26 @@ export const PATTERNS: RedactPattern[] = [
id: "env.kv",
tier: "MEDIUM",
category: "secret",
description: "Env-style SECRET assignment with high-entropy value",
regex: /^[ \t]*(?:export[ \t]+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)[ \t]*=[ \t]*['"]?([^\s'"]{8,})['"]?/,
// Only fire on high-entropy values — kills `FOO_KEY=changeme` FPs.
validate: (span) =>
description: "Secret-named assignment (env/YAML/JSON) with high-entropy value",
// #1946 gap 3: the original shape required an UPPERCASE name and an `=`
// assignment, so `api_key=…`, `apiKey: "…"`, and `password: …` (YAML/JSON
// colon form) produced NO finding at all — a detection fail-open on the
// most common config shapes. Now case-insensitive with `:` or `=`
// assignment and optional quotes around the key (JSON). Still MEDIUM and
// entropy-gated: this is the calibrated generic net, not a blocker.
// The name part is `[A-Za-z0-9_.-]*` + suffix (zero-or-more prefix, not
// one-or-more): a mandatory first char would swallow the suffix's own
// first letter and bare names like `password:` / `key:` would never match.
// The wide net is then calibrated by isCredentialShapedEnvName in
// validate — without it, any identifier that merely ENDS in a suffix
// (cacheKey:, sortKey:, monkey:) fires a MEDIUM confirm on entropic
// values. The value must stay capture group 1 (the engine masks group 1),
// so name-shape checking lives in validate, not in a second group.
regex: /^[ \t]*(?:export[ \t]+)?["']?[A-Za-z0-9_.-]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)["']?[ \t]*[:=][ \t]*["']?([^\s'"]{8,})["']?/i,
// Only fire on credential-shaped names with high-entropy values — kills
// `FOO_KEY=changeme` and `cacheKey: <entropic-id>` FPs.
validate: (span, match) =>
isCredentialShapedEnvName(match[0]) &&
!isPlaceholderSpan(span) &&
!/^\$\{?[A-Za-z_]/.test(span) &&
shannonEntropy(span) >= 3.0,
@@ -565,11 +666,13 @@ export const PATTERNS: RedactPattern[] = [
regex: /(?<![\w.])(\+?[1-9]\d{0,2}[ \-.]?\(?\d{2,4}\)?[ \-.]?\d{3,4}[ \-.]?\d{3,4})(?![\w.])/,
autoRedactable: true,
redactToken: "<REDACTED-PHONE>",
// A digit-only UUID's hyphen groups read as national phone formatting.
// A digit-only UUID's hyphen groups read as national phone formatting, and
// so does a county tax-map parcel ID (see looksLikeParcelId).
validate: (span, match) =>
!insideUuid(match) &&
span.replace(/\D/g, "").length >= 10 &&
!looksLikeCompactTimestamp(span),
!looksLikeCompactTimestamp(span) &&
!looksLikeParcelId(span, match),
},
{
id: "pii.ssn",
+130
View File
@@ -0,0 +1,130 @@
// version-source — where a repo's version lives, and how wide it is.
//
// gstack's native shape is a plain-text VERSION file at the repo root holding a
// 4-digit MAJOR.MINOR.PATCH.MICRO — and for gstack itself that file STAYS the
// source of truth (decision pinned in the v1.67 fix-wave plan: package.json is
// a translated mirror, never the authority). This module exists for the two
// real-world shapes that did not fit and both failed CLOSED in a way that
// silently disabled /ship's version tooling (#2501):
//
// 1. The version's home is a package.json — often not at the root (a monorepo
// whose frontend/package.json is the single source of truth because the
// build injects it). The --version-path / .gstack/version-path pin already
// let you point anywhere, but the readers treated the target as raw text,
// so a JSON file was whitespace-stripped into `{"name":"frontend",...` and
// every version read came back as the 0.0.0.0 fallback — including rival
// PRs' claims fetched through the GitHub Contents API, which were then
// dropped as "malformed".
// 2. The version is 3-digit semver. parseVersion() required exactly four
// components, so gstack-next-version exited 2 ("could not parse base
// version") on every invocation — and that CLI *is* the queue-collision
// check, so /ship fell through to its documented "offline" path of naive
// local arithmetic. Two branches cut from the same base then pick the same
// version, and git merges that without a conflict because both sides set
// one line to identical text. The duplicate slot ships silently.
//
// Both are handled here rather than in each CLI so the two agree by construction.
//
// Detection is by shape, not configuration: a version-path ending in .json is
// read as JSON (.version), anything else as trimmed text; a version string with
// three components stays three components through bumping and formatting. A
// repo with a root VERSION file and 4-digit versions sees no behaviour change.
//
// Re-derived from PR #2501 by @YiftahR.
export type Version = [number, number, number, number];
export type VersionWidth = 3 | 4;
export type Bump = "major" | "minor" | "patch" | "micro";
/** Parse 3- or 4-component versions. 3-digit pads to [a,b,c,0] so comparison stays uniform. */
export function parseVersion(s: string): Version | null {
const m = s.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?$/);
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4] ?? 0)];
}
/** How many components the string actually had — what to format back out as. */
export function versionWidth(s: string): VersionWidth {
return /^\d+\.\d+\.\d+\.\d+$/.test(s.trim()) ? 4 : 3;
}
export function fmtVersion(v: Version, width: VersionWidth = 4): string {
return v.slice(0, width).join(".");
}
export function cmpVersion(a: Version, b: Version): number {
for (let i = 0; i < 4; i++) {
if (a[i] !== b[i]) return a[i] - b[i];
}
return 0;
}
/**
* Bump one level. In a 3-digit repo there is no MICRO component to move, so
* `micro` is carried out as a PATCH: /ship auto-picks MICRO by default, and
* erroring there would make it unusable in every 3-digit repo — a silent no-op
* would be worse still, since the caller would then write back the version it
* started with and claim a taken slot.
*/
export function bumpVersion(v: Version, level: Bump, width: VersionWidth = 4): Version {
const effective: Bump = width === 3 && level === "micro" ? "patch" : level;
switch (effective) {
case "major":
return [v[0] + 1, 0, 0, 0];
case "minor":
return [v[0], v[1] + 1, 0, 0];
case "patch":
return [v[0], v[1], v[2] + 1, 0];
case "micro":
return [v[0], v[1], v[2], v[3] + 1];
}
}
/** True when the effective bump differs from the one asked for (so callers can say so). */
export function bumpWasCoerced(level: Bump, width: VersionWidth): boolean {
return width === 3 && level === "micro";
}
/**
* The npm-valid form of a gstack version. npm's semver is 3-component and
* rejects a fourth, so the 4-digit MAJOR.MINOR.PATCH.MICRO truncates to
* MAJOR.MINOR.PATCH; 3-digit versions pass through unchanged. Per the
* version-tooling end-state spec (v1.67 fix-wave plan, decision 11): the
* manifest mirror always carries this form, and VERSION stays the 4-digit
* source of truth.
*/
export function npmVersion(version: string): string {
return version.trim().split(".").slice(0, 3).join(".");
}
/** A version-path pointing at a .json is read as JSON, not as raw text. */
export function isJsonVersionPath(versionPath: string): boolean {
return /\.json$/i.test(versionPath.trim());
}
/**
* Pull the version out of whatever the version-path resolves to. `text` is the
* file's contents from anywhere — local read, `git show`, or a base64-decoded
* API response — so every reader agrees on interpretation. Returns "" when
* there is no usable version, which callers map to their own fallback.
*/
export function extractVersion(text: string, versionPath: string): string {
if (!isJsonVersionPath(versionPath)) return text.replace(/[\r\n\s]/g, "");
try {
const parsed = JSON.parse(text) as { version?: unknown };
return typeof parsed?.version === "string" ? parsed.version.trim() : "";
} catch {
return "";
}
}
/**
* Write a version back into a JSON file, preserving the rest of it. Deliberately
* key-order-preserving (JSON.parse/stringify keeps insertion order) and 2-space
* indented with a trailing newline, matching what package managers write.
*/
export function setVersionInJson(raw: string, version: string): string {
const parsed = JSON.parse(raw) as Record<string, unknown>;
parsed.version = version;
return JSON.stringify(parsed, null, 2) + "\n";
}