diff --git a/bin/gstack-config b/bin/gstack-config index d9834c447..15c7300c5 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -134,12 +134,12 @@ lookup_default() { redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection redact_prepush_hook) echo "false" ;; # Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline: - # brain_trust_policy@ — unset on fresh install; setup-gbrain + # brain_trust_policy@ — unset on fresh install; setup-gbrain # writes 'personal' for local engines, # asks the user for remote-ambiguous. # salience_allowlist — empty falls through to # SALIENCE_DEFAULT_ALLOWLIST (D9). - # user_slug_at_ — empty triggers resolve-user-slug + # user_slug_at_ — empty triggers resolve-user-slug # fallback chain (D4 A3) on first call. brain_trust_policy*) echo "unset" ;; salience_allowlist) echo "" ;; @@ -260,14 +260,16 @@ resolve_user_slug() { case "${1:-}" in get) KEY="${2:?Usage: gstack-config get }" - # Validate key (alphanumeric + underscore + optional @ suffix for - # endpoint-namespaced keys introduced by the brain-aware planning layer) - if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then - echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 + # Validate key (alphanumeric + underscore + optional @ suffix for + # endpoint-namespaced keys introduced by the brain-aware planning layer). + # Endpoint ids are sha8/sha16 hex for remote MCP URLs, or the literal + # "local" for stdio/PGLite engines (see endpoint_hash). + if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then + echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 exit 1 fi - # Use literal match for keys containing @ (sha hashes), regex otherwise - VALUE=$(grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | grep -E "^${KEY%@*}(@[a-f0-9]+)?:" | grep -F "${KEY}:" | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) + # Use literal match for keys containing @ (endpoint ids), regex otherwise + VALUE=$(grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | grep -E "^${KEY%@*}(@[a-zA-Z0-9]+)?:" | grep -F "${KEY}:" | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) if [ -z "$VALUE" ]; then VALUE=$(lookup_default "$KEY") fi @@ -276,9 +278,10 @@ case "${1:-}" in set) KEY="${2:?Usage: gstack-config set }" VALUE="${3:?Usage: gstack-config set }" - # Validate key (alphanumeric + underscore + optional @ suffix) - if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then - echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 + # Validate key (alphanumeric + underscore + optional @ suffix). + # Accepts hex hashes and the literal "local" from endpoint_hash. + if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then + echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 exit 1 fi # Validate brain_trust_policy value domain (D4 / D11) diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index ad2380df2..2100f913b 100755 --- a/bin/gstack-gbrain-detect +++ b/bin/gstack-gbrain-detect @@ -18,7 +18,7 @@ * "gstack_brain_sync_mode": "off"|"artifacts-only"|"full", * "gstack_brain_git": true|false, * "gstack_artifacts_remote": "https://..." | "", - * "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"|"timeout", + * "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"|"engine-locked"|"timeout", * "gbrain_pooler_mode": "transaction"|"session"|null * } * diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index e55cec0ad..07a94af3f 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -717,6 +717,7 @@ function dreamMarkerPid(): number | null { * missing-config → "no local engine; run /setup-gbrain to add local PGLite" * broken-config → "config file at ~/.gbrain/config.json is malformed; see /setup-gbrain Step 1.5" * broken-db → "config points at unreachable DB; see /setup-gbrain Step 1.5" + * engine-locked → PGLite is busy; stop its holder or sync outside the live session * timeout → kept for Record totality; stages PROCEED on timeout (#1964) * via the gate's warnProbeTimeout path, never this skip. */ @@ -733,6 +734,8 @@ function skipStageForLocalStatus( "config at ~/.gbrain/config.json is malformed; see /setup-gbrain Step 1.5", "broken-db": "config points at unreachable DB; see /setup-gbrain Step 1.5", + "engine-locked": + "PGLite is busy (often held by gbrain serve); stop the holding process or run /sync-gbrain outside the live Claude session, then retry", "timeout": "engine probe timed out; raise GSTACK_GBRAIN_PROBE_TIMEOUT_MS if your pooler is slow", }; diff --git a/bin/gstack-repo-mode b/bin/gstack-repo-mode index 0b4d6da64..0aabe378e 100755 --- a/bin/gstack-repo-mode +++ b/bin/gstack-repo-mode @@ -12,13 +12,20 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -# Compute SLUG directly (avoid eval of gstack-slug — branch names can contain shell metacharacters) +# Keep the no-remote early exit before consulting gstack-slug. gstack-slug falls +# back to the directory basename when origin is missing, which would incorrectly +# make local-only repos eligible for classification. REMOTE_URL=$(git remote get-url origin 2>/dev/null || true) if [ -z "$REMOTE_URL" ]; then echo "REPO_MODE=unknown" exit 0 fi -SLUG=$(echo "$REMOTE_URL" | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-') +# Consume the canonical cached slug from gstack-slug (sed, never eval — branch +# names can contain shell metacharacters). Invoke from the git root so the +# PWD-keyed slug cache matches other gstack project-state writers. +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) +[ -z "${REPO_ROOT:-}" ] && { echo "REPO_MODE=unknown"; exit 0; } +SLUG=$(cd "$REPO_ROOT" && "$SCRIPT_DIR/gstack-slug" | sed -n 's/^SLUG=//p' | head -1) [ -z "${SLUG:-}" ] && { echo "REPO_MODE=unknown"; exit 0; } # Validate: only allow known values (prevent shell injection via source <(...)) diff --git a/browse/src/content-security.ts b/browse/src/content-security.ts index 81993271b..a18576458 100644 --- a/browse/src/content-security.ts +++ b/browse/src/content-security.ts @@ -338,19 +338,28 @@ const BLOCKLIST_DOMAINS = [ export function urlBlocklistFilter(content: string, url: string, _command: string): ContentFilterResult { const warnings: string[] = []; + // Matching is case-insensitive. Hostnames and URL schemes are + // case-insensitive (RFC 3986), so an uppercased sink like + // https://WEBHOOK.SITE/x must not slip past the blocklist. Normalize the + // haystack to lowercase; BLOCKLIST_DOMAINS entries are lowercase by + // construction, so a direct compare then holds. + // Check page URL + const normalizedUrl = url.toLowerCase(); for (const domain of BLOCKLIST_DOMAINS) { - if (url.includes(domain)) { + if (normalizedUrl.includes(domain)) { warnings.push(`Page URL matches blocklisted domain: ${domain}`); } } - // Check for blocklisted URLs in content (links, form actions) - const urlPattern = /https?:\/\/[^\s"'<>]+/g; + // Check for blocklisted URLs in content (links, form actions). The `i` flag + // keeps an uppercased scheme (HTTPS://) from evading URL extraction. + const urlPattern = /https?:\/\/[^\s"'<>]+/gi; const contentUrls = content.match(urlPattern) || []; for (const contentUrl of contentUrls) { + const normalizedContentUrl = contentUrl.toLowerCase(); for (const domain of BLOCKLIST_DOMAINS) { - if (contentUrl.includes(domain)) { + if (normalizedContentUrl.includes(domain)) { warnings.push(`Content contains blocklisted URL: ${contentUrl.slice(0, 100)}`); break; } diff --git a/browse/test/content-security.test.ts b/browse/test/content-security.test.ts index 8c760460d..1682fb7a3 100644 --- a/browse/test/content-security.test.ts +++ b/browse/test/content-security.test.ts @@ -151,6 +151,44 @@ describe('Content filter hooks', () => { expect(result.warnings.length).toBe(0); }); + // Regression: issue #2190 — case-sensitive matching let an uppercased + // exfiltration domain bypass the blocklist. + test('URL blocklist is case-insensitive on the page URL', () => { + const result = urlBlocklistFilter('', 'https://WEBHOOK.SITE/steal', 'text'); + expect(result.safe).toBe(false); + expect(result.warnings.some(w => w.includes('webhook.site'))).toBe(true); + }); + + test('URL blocklist is case-insensitive on content URLs', () => { + const result = urlBlocklistFilter( + 'click', + 'https://docs.example.com/article', + 'links', + ); + expect(result.safe).toBe(false); + expect(result.warnings.some(w => w.includes('WEBHOOK.SITE'))).toBe(true); + }); + + test('URL blocklist catches an uppercased scheme in content', () => { + const result = urlBlocklistFilter( + 'Exfil via HTTPS://Requestbin.com/r/abc please', + 'https://example.com', + 'text', + ); + expect(result.safe).toBe(false); + expect(result.warnings.some(w => w.toLowerCase().includes('requestbin.com'))).toBe(true); + }); + + test('URL blocklist does not over-block legitimate uppercase domains', () => { + const result = urlBlocklistFilter( + 'source', + 'https://DOCS.EXAMPLE.COM/help', + 'links', + ); + expect(result.safe).toBe(true); + expect(result.warnings.length).toBe(0); + }); + test('custom filter can be registered and runs', () => { registerContentFilter((content, url, cmd) => { if (content.includes('SECRET')) { diff --git a/browse/test/gstack-config.test.ts b/browse/test/gstack-config.test.ts index e342a50b7..51adbe813 100644 --- a/browse/test/gstack-config.test.ts +++ b/browse/test/gstack-config.test.ts @@ -108,6 +108,13 @@ describe('gstack-config', () => { expect(existsSync(join(nestedDir, 'config.yaml'))).toBe(true); }); + test('brain trust policy accepts local endpoint suffix', () => { + const { exitCode, stderr } = run(['set', 'brain_trust_policy@local', 'personal']); + expect(exitCode).toBe(0); + expect(stderr).toBe(''); + expect(run(['get', 'brain_trust_policy@local']).stdout).toBe('personal'); + }); + // ─── list ───────────────────────────────────────────────── test('list shows all keys', () => { writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\nupdate_check: false\n'); @@ -139,6 +146,12 @@ describe('gstack-config', () => { expect(stderr).toContain('alphanumeric'); }); + test('set rejects endpoint suffix with punctuation', () => { + const { exitCode, stderr } = run(['set', 'brain_trust_policy@local-dev', 'personal']); + expect(exitCode).toBe(1); + expect(stderr).toContain('endpoint-id'); + }); + test('set preserves value with sed special chars', () => { run(['set', 'test_special', 'a/b&c\\d']); const { stdout } = run(['get', 'test_special']); diff --git a/bun.lock b/bun.lock index 7a1833e94..90b8fddf4 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,9 @@ }, }, }, + "overrides": { + "basic-ftp": "5.3.1", + }, "packages": { "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.117", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.117", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.117" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-pVBss1Vu0w87nKCBhWtjMggSgCh6GVUtdRmuE58ZvXv0E2q0JcnUCQHehmn92BAW0+VCwPY8q/k7uKWkgwz/gA=="], @@ -201,7 +204,7 @@ "bare-url": ["bare-url@2.4.0", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA=="], - "basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="], + "basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], diff --git a/lib/gbrain-local-status.ts b/lib/gbrain-local-status.ts index 9e26d5ba4..2b6caa780 100644 --- a/lib/gbrain-local-status.ts +++ b/lib/gbrain-local-status.ts @@ -19,6 +19,8 @@ * Broken-config → config exists but `gbrain sources list` fails with config parse error * (or any non-recognized error — defensive default per codex #8). * Broken-db → config exists, DB unreachable per stderr classification. + * Engine-locked → PGLite probe hit gbrain's own connect timeout, usually + * because another `gbrain serve` process owns the embedded DB. * 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. @@ -47,6 +49,7 @@ export type LocalEngineStatus = | "missing-config" | "broken-config" | "broken-db" + | "engine-locked" | "timeout"; export interface ClassifyOptions { @@ -118,6 +121,15 @@ function gbrainConfigPath(env?: NodeJS.ProcessEnv): string { return join(gbrainHome, "config.json"); } +function configuredEngine(env?: NodeJS.ProcessEnv): "pglite" | "postgres" | null { + try { + const parsed = JSON.parse(readFileSync(gbrainConfigPath(env), "utf-8")) as { engine?: string }; + return parsed.engine === "pglite" || parsed.engine === "postgres" ? parsed.engine : null; + } catch { + return null; + } +} + function hashPath(p: string): string { return createHash("sha256").update(p).digest("hex").slice(0, 16); } @@ -281,6 +293,7 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus { stderr?: Buffer | string; killed?: boolean; signal?: NodeJS.Signals | null; + status?: number | null; }; const stderr = (e.stderr ? e.stderr.toString() : "") || ""; @@ -292,6 +305,14 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus { 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"; + } + // 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. diff --git a/package.json b/package.json index c5168d648..846438a60 100644 --- a/package.json +++ b/package.json @@ -76,5 +76,8 @@ "@anthropic-ai/sdk": "^0.78.0", "xterm": "5", "xterm-addon-fit": "^0.8.0" + }, + "overrides": { + "basic-ftp": "5.3.1" } } diff --git a/setup-gbrain/SKILL.md b/setup-gbrain/SKILL.md index 89c2ffbc3..a3892f1c9 100644 --- a/setup-gbrain/SKILL.md +++ b/setup-gbrain/SKILL.md @@ -820,7 +820,7 @@ Capture the JSON output. It contains: `gbrain_on_path`, `gbrain_version`, `gbrain_config_exists`, `gbrain_engine`, `gbrain_doctor_ok`, `gbrain_mcp_mode`, `gstack_brain_sync_mode`, `gstack_brain_git`, `gstack_artifacts_remote`, and the v1.34.0.0+ `gbrain_local_status` field (one of: `ok`, `no-cli`, -`missing-config`, `broken-config`, `broken-db`, `timeout`). Treat `timeout` +`missing-config`, `broken-config`, `broken-db`, `engine-locked`, `timeout`). Treat `timeout` like `ok` (slow-but-healthy engine, #1964) — it never triggers Step 1.5 remediation. diff --git a/setup-gbrain/SKILL.md.tmpl b/setup-gbrain/SKILL.md.tmpl index f48581543..f475987c1 100644 --- a/setup-gbrain/SKILL.md.tmpl +++ b/setup-gbrain/SKILL.md.tmpl @@ -66,7 +66,7 @@ Capture the JSON output. It contains: `gbrain_on_path`, `gbrain_version`, `gbrain_config_exists`, `gbrain_engine`, `gbrain_doctor_ok`, `gbrain_mcp_mode`, `gstack_brain_sync_mode`, `gstack_brain_git`, `gstack_artifacts_remote`, and the v1.34.0.0+ `gbrain_local_status` field (one of: `ok`, `no-cli`, -`missing-config`, `broken-config`, `broken-db`, `timeout`). Treat `timeout` +`missing-config`, `broken-config`, `broken-db`, `engine-locked`, `timeout`). Treat `timeout` like `ok` (slow-but-healthy engine, #1964) — it never triggers Step 1.5 remediation. diff --git a/sync-gbrain/SKILL.md b/sync-gbrain/SKILL.md index bfaf291d2..02055bb6f 100644 --- a/sync-gbrain/SKILL.md +++ b/sync-gbrain/SKILL.md @@ -893,6 +893,10 @@ BEFORE invoking the orchestrator: slow (cold pooler connection, #1964). Tell the user in one line: "Engine probe timed out (>15s) — proceeding; raise `GSTACK_GBRAIN_PROBE_TIMEOUT_MS` if your pooler is slow." Do NOT treat this as a broken config. +- **`engine-locked`**: STOP. "The local PGLite database is busy, usually + because `gbrain serve` from a live Claude session owns it. Stop that process + or run `/sync-gbrain` outside the live session, then retry. This identifies + the conflict but does not remove PGLite's single-process limit." - **`no-cli`**: STOP. "Local gbrain CLI not installed. Run `/setup-gbrain` first." - **`missing-config`** AND `gbrain_mcp_mode == "remote-http"`: tell the user diff --git a/sync-gbrain/SKILL.md.tmpl b/sync-gbrain/SKILL.md.tmpl index 2ec065472..aa97f7eb7 100644 --- a/sync-gbrain/SKILL.md.tmpl +++ b/sync-gbrain/SKILL.md.tmpl @@ -139,6 +139,10 @@ BEFORE invoking the orchestrator: slow (cold pooler connection, #1964). Tell the user in one line: "Engine probe timed out (>15s) — proceeding; raise `GSTACK_GBRAIN_PROBE_TIMEOUT_MS` if your pooler is slow." Do NOT treat this as a broken config. +- **`engine-locked`**: STOP. "The local PGLite database is busy, usually + because `gbrain serve` from a live Claude session owns it. Stop that process + or run `/sync-gbrain` outside the live session, then retry. This identifies + the conflict but does not remove PGLite's single-process limit." - **`no-cli`**: STOP. "Local gbrain CLI not installed. Run `/setup-gbrain` first." - **`missing-config`** AND `gbrain_mcp_mode == "remote-http"`: tell the user diff --git a/test/basic-ftp-security-pin.test.ts b/test/basic-ftp-security-pin.test.ts new file mode 100644 index 000000000..a2e9f9f8e --- /dev/null +++ b/test/basic-ftp-security-pin.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect } from 'bun:test'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const ROOT = join(import.meta.dir, '..'); + +// Security regression guard for the basic-ftp transitive dependency. +// +// basic-ftp reaches the tree only transitively: +// puppeteer-core > @puppeteer/browsers > proxy-agent > pac-proxy-agent > get-uri > basic-ftp +// +// Versions <= 5.3.0 carry four HIGH advisories, all fixed in 5.3.1: +// - GHSA-chqc-8p9q-pq6q CVE-2026-39983 FTP command injection via CRLF (fixed 5.2.1) +// - GHSA-6v7q-wjvx-w8wg incomplete CRLF protection, USER/PASS + MKD bypass (fixed 5.2.2) +// - GHSA-rpmf-866q-6p89 DoS via unbounded multiline control-response buffering +// - GHSA-rp42-5vxx-qpwr DoS via unbounded memory in Client.list() +// +// The fix is a bun `overrides` pin (not a phantom direct dependency): overriding +// forces EVERY basic-ftp in the tree to the safe version, including get-uri's +// nested copy. A direct-dependency bump leaves that nested copy behind — which is +// exactly the failure mode this test is here to catch. See the CVE-2026-39983 +// upgrade fix for the full rationale. +const MIN_SAFE = '5.3.1'; + +function cmpSemver(a: string, b: string): number { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0); + } + return 0; +} + +describe('basic-ftp security pin (CVE-2026-39983 and siblings)', () => { + test('package.json overrides basic-ftp to a safe version', () => { + const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')); + const pin = pkg.overrides?.['basic-ftp']; + expect(pin, 'package.json overrides.basic-ftp must exist').toBeDefined(); + const pinned = String(pin).replace(/^[\^~]/, ''); + expect( + cmpSemver(pinned, MIN_SAFE) >= 0, + `overrides.basic-ftp is "${pin}" but must be >= ${MIN_SAFE}`, + ).toBe(true); + }); + + test('no basic-ftp entry in bun.lock resolves below the safe version', () => { + const lock = readFileSync(join(ROOT, 'bun.lock'), 'utf-8'); + // Match every resolved basic-ftp specifier, including nested paths like + // "get-uri/basic-ftp" that a direct-dependency bump would leave vulnerable. + const versions = [...lock.matchAll(/basic-ftp@(\d+\.\d+\.\d+)/g)].map(m => m[1]); + expect(versions.length, 'expected at least one basic-ftp entry in bun.lock').toBeGreaterThan(0); + const vulnerable = versions.filter(v => cmpSemver(v, MIN_SAFE) < 0); + expect( + vulnerable, + `bun.lock still resolves vulnerable basic-ftp version(s): ${vulnerable.join(', ')}`, + ).toEqual([]); + }); +}); diff --git a/test/gbrain-local-status.test.ts b/test/gbrain-local-status.test.ts index 703adfcad..0d5a44539 100644 --- a/test/gbrain-local-status.test.ts +++ b/test/gbrain-local-status.test.ts @@ -6,13 +6,14 @@ * on PATH that emits canned exit codes + stderr matching the patterns the * classifier looks for. * - * Six status cases: + * Seven status cases: * 1. no-cli — gbrain absent from PATH * 2. missing-config — gbrain present, config.json absent (honors GBRAIN_HOME) * 3. broken-config — gbrain present, config exists, stderr contains "config.json" * 4. broken-db — gbrain present, config exists, stderr contains "Cannot connect to database" * 5. timeout — probe exceeds GSTACK_GBRAIN_PROBE_TIMEOUT_MS with no recognized error (#1964) - * 6. ok — gbrain present, config exists, sources list returns valid JSON + * 6. engine-locked — PGLite CLI exits 124 because another process owns the DB (#2194) + * 7. ok — gbrain present, config exists, sources list returns valid JSON * * Plus cache behavior: hit, TTL expiry, invariant invalidation (HOME change, * probe-timeout change), --no-cache bypass. Timeout tests keep runtime sane by @@ -61,7 +62,7 @@ interface FakeEnv { */ function makeEnv(opts: { withGbrain?: boolean; - gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "throws" | "slow"; + gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "engine-locked" | "throws" | "slow"; withConfig?: boolean; }): FakeEnv { const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-")); @@ -102,7 +103,7 @@ function makeEnv(opts: { } function makeFakeGbrainScript( - behavior: "ok" | "broken-db" | "broken-config" | "throws" | "slow", + behavior: "ok" | "broken-db" | "broken-config" | "engine-locked" | "throws" | "slow", ): string { // "slow": healthy engine on a cold pooler connection (#1964) — sleeps past // the (test-lowered) probe timeout, then would answer fine. @@ -125,10 +126,12 @@ exit 0 ? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2' : behavior === "broken-config" ? 'echo "Error: malformed config.json at ~/.gbrain/config.json" >&2' + : behavior === "engine-locked" + ? 'echo "gbrain sources: connect timed out (default 10000ms; pass --timeout=Ns to override)." >&2' : behavior === "throws" ? 'echo "unexpected gbrain failure" >&2' : ""; - const exitCode = behavior === "ok" ? 0 : 1; + const exitCode = behavior === "ok" ? 0 : behavior === "engine-locked" ? 124 : 1; return `#!/bin/sh if [ "$1" = "--version" ]; then echo "gbrain 0.33.1.0" @@ -225,6 +228,19 @@ describe("lib/gbrain-local-status — status classification", () => { expect(localEngineStatus({ noCache: true })).toBe("broken-config"); }); + it("returns 'engine-locked' when PGLite exits 124 with its own connect timeout (#2194)", () => { + env = makeEnv({ withGbrain: true, gbrainBehavior: "engine-locked", withConfig: true }); + restoreEnv = applyEnv(env); + expect(localEngineStatus({ noCache: true })).toBe("engine-locked"); + }); + + it("classifies a non-PGLite connect timeout as unreachable DB, not malformed config", () => { + env = makeEnv({ withGbrain: true, gbrainBehavior: "engine-locked", withConfig: true }); + restoreEnv = applyEnv(env); + writeFileSync(env.configPath, JSON.stringify({ engine: "postgres", database_url: "postgres://fake" })); + expect(localEngineStatus({ noCache: true })).toBe("broken-db"); + }); + it("returns 'ok' when sources list succeeds", () => { env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true }); restoreEnv = applyEnv(env); diff --git a/test/gbrain-sync-skip.test.ts b/test/gbrain-sync-skip.test.ts index 1d7423c98..902256b3b 100644 --- a/test/gbrain-sync-skip.test.ts +++ b/test/gbrain-sync-skip.test.ts @@ -42,7 +42,7 @@ interface FakeEnv { */ function makeEnv(opts: { withGbrain: boolean; - gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "slow"; + gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "engine-locked" | "slow"; withConfig: boolean; }): FakeEnv { const tmp = mkdtempSync(join(tmpdir(), "gbrain-sync-skip-")); @@ -75,6 +75,9 @@ function makeEnv(opts: { : behavior === "ok" ? ` echo '{"sources":[]}' exit 0` + : behavior === "engine-locked" + ? ` echo "gbrain sources: connect timed out (default 10000ms; pass --timeout=Ns to override)." >&2 + exit 124` : ` ${ behavior === "broken-db" ? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2' @@ -207,6 +210,20 @@ describe("gstack-gbrain-sync — split-engine SKIP (plan D12)", () => { } }); + it("SKIPs with actionable guidance when PGLite is held by gbrain serve (#2194)", () => { + const env = makeEnv({ withGbrain: true, gbrainBehavior: "engine-locked", withConfig: true }); + try { + const r = runOrchestrator(env, ["--code-only"]); + const out = r.stdout + r.stderr; + expect(out).toContain("local engine engine-locked"); + expect(out).toContain("gbrain serve"); + expect(out).toContain("outside the live Claude session"); + expect(out).not.toContain("config.json is malformed"); + } finally { + env.cleanup(); + } + }); + it("SKIPs code stage when gbrain CLI is missing (no-cli)", () => { const env = makeEnv({ withGbrain: false, withConfig: false }); try { diff --git a/test/gstack-repo-mode.test.ts b/test/gstack-repo-mode.test.ts new file mode 100644 index 000000000..62088a217 --- /dev/null +++ b/test/gstack-repo-mode.test.ts @@ -0,0 +1,176 @@ +/** + * Project-slug consistency for gstack-repo-mode. + * + * These are end-to-end reproductions of #2212. Each case is a real local Git + * workspace which predates its public GitHub origin: gstack-slug caches the + * local directory name, then the origin is added. repo-mode must write its + * cache next to that canonical project state, not beside a newly derived + * owner-repo slug. + */ + +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const REPO_MODE_BIN = path.join(ROOT, 'bin', 'gstack-repo-mode'); +const SLUG_BIN = path.join(ROOT, 'bin', 'gstack-slug'); + +type CommandResult = { stdout: string; stderr: string; status: number }; + +function run(command: string, args: string[], cwd: string, home: string): CommandResult { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + env: { ...process.env, HOME: home, GSTACK_HOME: path.join(home, '.gstack') }, + }); + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + status: result.status ?? -1, + }; +} + +function git(args: string[], cwd: string, home: string) { + const result = run('git', args, cwd, home); + expect(result.status, `${result.stderr}\n${result.stdout}`).toBe(0); +} + +function slugFrom(output: string): string { + const line = output.split('\n').find((entry) => entry.startsWith('SLUG=')); + expect(line).toBeDefined(); + return line!.slice('SLUG='.length); +} + +const publicWorkspaces = [ + { name: 'react-local-history', origin: 'https://github.com/facebook/react.git' }, + { name: 'next-local-history', origin: 'https://github.com/vercel/next.js.git' }, + { name: 'kubernetes-local-history', origin: 'https://github.com/kubernetes/kubernetes.git' }, +]; + +describe('gstack-repo-mode cached slug consistency (#2212)', () => { + for (const workspace of publicWorkspaces) { + test(`${workspace.origin} keeps its pre-origin project cache`, () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-home-')); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-workspaces-')); + const project = path.join(root, workspace.name); + + try { + fs.mkdirSync(project); + git(['init', '-b', 'main'], project, home); + git(['config', 'user.name', 'gstack regression test'], project, home); + git(['config', 'user.email', 'test@gstack.dev'], project, home); + + // This workspace existed and used gstack before it adopted GitHub. + const initialSlug = slugFrom(run(SLUG_BIN, [], project, home).stdout); + expect(initialSlug).toBe(workspace.name); + + // Five commits make repo-mode perform its real classification path. + for (let commit = 1; commit <= 5; commit += 1) { + fs.writeFileSync(path.join(project, `commit-${commit}.txt`), String(commit)); + git(['add', '.'], project, home); + git(['commit', '-m', `commit ${commit}`], project, home); + } + git(['remote', 'add', 'origin', workspace.origin], project, home); + + const mode = run(REPO_MODE_BIN, [], project, home); + expect(mode.status, mode.stderr).toBe(0); + expect(mode.stdout.trim()).toBe('REPO_MODE=solo'); + + // Canonical slug stays the pre-origin basename; remote-derived twin must not appear. + const projectsDir = path.join(home, '.gstack', 'projects'); + const canonicalCache = path.join(projectsDir, initialSlug, 'repo-mode.json'); + expect(fs.existsSync(canonicalCache)).toBe(true); + expect(fs.readdirSync(projectsDir)).toEqual([initialSlug]); + + // Slug remains sanitized even when the remote URL would otherwise diverge. + expect(initialSlug).toMatch(/^[a-zA-Z0-9._-]+$/); + expect(slugFrom(run(SLUG_BIN, [], project, home).stdout)).toBe(initialSlug); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + } + + test('still treats a workspace without origin as unknown', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-home-')); + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-local-only-')); + try { + git(['init', '-b', 'main'], project, home); + const mode = run(REPO_MODE_BIN, [], project, home); + expect(mode.status, mode.stderr).toBe(0); + expect(mode.stdout.trim()).toBe('REPO_MODE=unknown'); + expect(fs.existsSync(path.join(home, '.gstack', 'projects'))).toBe(false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(project, { recursive: true, force: true }); + } + }); + + test('subdir invocation still writes under the git-root cached slug', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-home-')); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-subdir-')); + const project = path.join(root, 'nested-history'); + const nested = path.join(project, 'packages', 'app'); + try { + fs.mkdirSync(nested, { recursive: true }); + git(['init', '-b', 'main'], project, home); + git(['config', 'user.name', 'gstack regression test'], project, home); + git(['config', 'user.email', 'test@gstack.dev'], project, home); + + const initialSlug = slugFrom(run(SLUG_BIN, [], project, home).stdout); + expect(initialSlug).toBe('nested-history'); + + for (let commit = 1; commit <= 5; commit += 1) { + fs.writeFileSync(path.join(project, `commit-${commit}.txt`), String(commit)); + git(['add', '.'], project, home); + git(['commit', '-m', `commit ${commit}`], project, home); + } + git(['remote', 'add', 'origin', 'https://github.com/facebook/react.git'], project, home); + + const mode = run(REPO_MODE_BIN, [], nested, home); + expect(mode.status, mode.stderr).toBe(0); + expect(mode.stdout.trim()).toBe('REPO_MODE=solo'); + + const projectsDir = path.join(home, '.gstack', 'projects'); + expect(fs.existsSync(path.join(projectsDir, initialSlug, 'repo-mode.json'))).toBe(true); + expect(fs.readdirSync(projectsDir)).toEqual([initialSlug]); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test('sanitizes unusual origin URLs via gstack-slug before mkdir', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-home-')); + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-weird-url-')); + try { + git(['init', '-b', 'main'], project, home); + git(['config', 'user.name', 'gstack regression test'], project, home); + git(['config', 'user.email', 'test@gstack.dev'], project, home); + + for (let commit = 1; commit <= 5; commit += 1) { + fs.writeFileSync(path.join(project, `commit-${commit}.txt`), String(commit)); + git(['add', '.'], project, home); + git(['commit', '-m', `commit ${commit}`], project, home); + } + // Free-form remote URL with characters outside [a-zA-Z0-9._-] + git(['remote', 'add', 'origin', 'https://example.com/org/my repo (v2).git'], project, home); + + const mode = run(REPO_MODE_BIN, [], project, home); + expect(mode.status, mode.stderr).toBe(0); + expect(mode.stdout.trim()).toBe('REPO_MODE=solo'); + + const projects = fs.readdirSync(path.join(home, '.gstack', 'projects')); + expect(projects).toHaveLength(1); + expect(projects[0]).toMatch(/^[a-zA-Z0-9._-]+$/); + expect(projects[0]).not.toMatch(/[ ()]/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(project, { recursive: true, force: true }); + } + }); +}); diff --git a/test/helpers/gemini-session-runner.ts b/test/helpers/gemini-session-runner.ts index 4f2f040e3..3b58c79ca 100644 --- a/test/helpers/gemini-session-runner.ts +++ b/test/helpers/gemini-session-runner.ts @@ -8,7 +8,8 @@ * Key differences from Codex session-runner: * - Uses `gemini -p` instead of `codex exec` * - Output is NDJSON with event types: init, message, tool_use, tool_result, result - * - Uses `--output-format stream-json --yolo` instead of `--json -s read-only` + * - Uses `--output-format stream-json --yolo --skip-trust` instead of `--json -s read-only` + * (`--skip-trust` required for headless/untrusted cwds; see gemini trusted-folders docs) * - No temp HOME needed — Gemini discovers skills from `.agents/skills/` in cwd * - Message events are streamed with `delta: true` — must concatenate */ @@ -121,7 +122,9 @@ export async function runGeminiSkill(opts: { } // Build gemini command - const args = ['-p', prompt, '--output-format', 'stream-json', '--yolo']; + // --skip-trust: headless/CI and temp cwds aren't in ~/.gemini/trustedFolders.json; + // without it gemini exits FatalUntrustedWorkspaceError before any model call. + const args = ['-p', prompt, '--output-format', 'stream-json', '--yolo', '--skip-trust']; // Spawn gemini — uses real HOME for auth (~/.gemini; HOME is allowlisted), // cwd for skill discovery. Hermetic scrub with gemini's auth surface diff --git a/test/helpers/providers/gemini.test.ts b/test/helpers/providers/gemini.test.ts new file mode 100644 index 000000000..a0f404e29 --- /dev/null +++ b/test/helpers/providers/gemini.test.ts @@ -0,0 +1,138 @@ +import { describe, test, expect } from 'bun:test'; +import { parseGeminiStreamJson, resultFromGeminiStream } from './gemini'; + +// Current CLI shape (gemini ≥ ~0.11 / stream-json): content + role, tokens under result.stats +// Documented at https://geminicli.com/docs/cli/headless/ and gemini-cli PR #10883. +const CURRENT_CLI_FIXTURE = [ + '{"type":"init","timestamp":"2026-03-20T15:14:46.455Z","session_id":"test-session-123","model":"gemini-2.5-pro"}', + '{"type":"message","role":"user","content":"Reply with exactly the word: PONG"}', + '{"type":"message","role":"assistant","content":"PONG","delta":true}', + '{"type":"result","status":"success","stats":{"input_tokens":24946,"output_tokens":32,"total_tokens":24978}}', +].join('\n'); + +// Legacy shape: text field, tokens under result.usage +const LEGACY_FIXTURE = [ + '{"type":"message","text":"hello"}', + '{"type":"tool_use","name":"run_shell_command"}', + '{"type":"result","usage":{"input_token_count":100,"output_token_count":5},"model":"gemini-2.5-flash"}', +].join('\n'); + +describe('parseGeminiStreamJson', () => { + test('current CLI: reads assistant content, ignores user echo, stats tokens, init model', () => { + const parsed = parseGeminiStreamJson(CURRENT_CLI_FIXTURE); + expect(parsed.output).toBe('PONG'); + expect(parsed.tokens.input).toBe(24946); + expect(parsed.tokens.output).toBe(32); + expect(parsed.modelUsed).toBe('gemini-2.5-pro'); + expect(parsed.toolCalls).toBe(0); + }); + + test('current CLI: user role content must not concat into output', () => { + const raw = [ + '{"type":"message","role":"user","content":"PROMPT ECHO"}', + '{"type":"message","role":"assistant","content":"ok","delta":true}', + ].join('\n'); + const parsed = parseGeminiStreamJson(raw); + expect(parsed.output).toBe('ok'); + expect(parsed.output).not.toContain('PROMPT ECHO'); + }); + + test('legacy: still accepts text + usage.input_token_count', () => { + const parsed = parseGeminiStreamJson(LEGACY_FIXTURE); + expect(parsed.output).toBe('hello'); + expect(parsed.tokens.input).toBe(100); + expect(parsed.tokens.output).toBe(5); + expect(parsed.toolCalls).toBe(1); + expect(parsed.modelUsed).toBe('gemini-2.5-flash'); + }); + + test('legacy text with role:user is ignored', () => { + const raw = [ + '{"type":"message","role":"user","text":"echo"}', + '{"type":"message","role":"assistant","text":"kept"}', + ].join('\n'); + const parsed = parseGeminiStreamJson(raw); + expect(parsed.output).toBe('kept'); + }); + + test('concatenates multiple assistant content deltas', () => { + const raw = [ + '{"type":"message","role":"assistant","content":"A","delta":true}', + '{"type":"message","role":"assistant","content":"B","delta":true}', + ].join('\n'); + expect(parseGeminiStreamJson(raw).output).toBe('AB'); + }); + + test('skips malformed lines without throwing', () => { + const raw = [ + '{"type":"init","model":"m1"}', + 'not json', + '{"type":"message","role":"assistant","content":"x","delta":true}', + '{incomplete', + '{"type":"result","stats":{"input_tokens":1,"output_tokens":2}}', + ].join('\n'); + const parsed = parseGeminiStreamJson(raw); + expect(parsed.output).toBe('x'); + expect(parsed.tokens).toEqual({ input: 1, output: 2 }); + expect(parsed.modelUsed).toBe('m1'); + }); + + test('empty / whitespace-only input yields empty parse (no throw)', () => { + const parsed = parseGeminiStreamJson(''); + expect(parsed.output).toBe(''); + expect(parsed.tokens).toEqual({ input: 0, output: 0 }); + expect(parsed.toolCalls).toBe(0); + expect(parsed.modelUsed).toBeUndefined(); + }); + + test('result.model overrides init.model when both present', () => { + const raw = [ + '{"type":"init","model":"from-init"}', + '{"type":"message","role":"assistant","content":"hi"}', + '{"type":"result","model":"from-result","stats":{"input_tokens":1,"output_tokens":1}}', + ].join('\n'); + expect(parseGeminiStreamJson(raw).modelUsed).toBe('from-result'); + }); +}); + +describe('resultFromGeminiStream (adapter post-CLI e2e)', () => { + test('current CLI fixture → success row with PONG + tokens (not $0)', () => { + const result = resultFromGeminiStream(CURRENT_CLI_FIXTURE, { durationMs: 42 }); + expect(result.error).toBeUndefined(); + expect(result.output).toBe('PONG'); + expect(result.tokens.input).toBe(24946); + expect(result.tokens.output).toBe(32); + expect(result.modelUsed).toBe('gemini-2.5-pro'); + expect(result.durationMs).toBe(42); + // Cost signal: non-zero tokens means estimateCost will not be $0. + expect(result.tokens.input + result.tokens.output).toBeGreaterThan(0); + }); + + test('legacy text-only success still works', () => { + const result = resultFromGeminiStream(LEGACY_FIXTURE, { durationMs: 10 }); + expect(result.error).toBeUndefined(); + expect(result.output).toBe('hello'); + expect(result.toolCalls).toBe(1); + }); + + test('empty exit-0 stream → error row, never silent $0 success (#2159)', () => { + const emptySuccess = [ + '{"type":"init","model":"gemini-2.5-pro"}', + '{"type":"message","role":"user","content":"hi"}', + '{"type":"result","status":"success","stats":{"input_tokens":0,"output_tokens":0}}', + ].join('\n'); + const result = resultFromGeminiStream(emptySuccess, { durationMs: 5 }); + expect(result.error).toBeDefined(); + expect(result.error!.code).toBe('unknown'); + expect(result.error!.reason).toContain('empty output'); + expect(result.output).toBe(''); + expect(result.modelUsed).toBe('gemini-2.5-pro'); + }); + + test('pre-fix bug shape (text field missing, only content) would have been empty — now succeeds', () => { + // This is the exact failure mode from #2159: content present, no text. + const result = resultFromGeminiStream(CURRENT_CLI_FIXTURE); + expect(result.error).toBeUndefined(); + expect(result.output).toBe('PONG'); + }); +}); diff --git a/test/helpers/providers/gemini.ts b/test/helpers/providers/gemini.ts index 5e7abba13..b53d725d1 100644 --- a/test/helpers/providers/gemini.ts +++ b/test/helpers/providers/gemini.ts @@ -5,13 +5,108 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +export type GeminiStreamParse = { + output: string; + tokens: { input: number; output: number }; + toolCalls: number; + modelUsed?: string; +}; + +/** + * Parse gemini NDJSON stream events (exported for unit tests). + * + * Current CLI (`--output-format stream-json`) emits: + * init → model + * message { role, content, delta? } → concat assistant content + * tool_use → increment toolCalls + * result { stats: { input_tokens, output_tokens } } → tokens + * + * Legacy shape (still accepted): + * message { text } → concat text + * result { usage: { input_token_count, output_token_count } } → tokens + */ +export function parseGeminiStreamJson(raw: string): GeminiStreamParse { + let output = ''; + let input = 0; + let out = 0; + let toolCalls = 0; + let modelUsed: string | undefined; + for (const line of raw.split('\n')) { + const s = line.trim(); + if (!s) continue; + try { + const obj = JSON.parse(s); + if (obj.type === 'init') { + if (typeof obj.model === 'string' && obj.model) modelUsed = obj.model; + } else if (obj.type === 'message') { + // Current CLI: content + role. Role guard is required — the CLI echoes + // the user prompt as role:'user', which must not land in output. + if (obj.role === 'assistant' && typeof obj.content === 'string') { + output += obj.content; + } else if (typeof obj.text === 'string' && obj.role !== 'user') { + // Legacy text field (no role, or assistant). + output += obj.text; + } + } else if (obj.type === 'tool_use') { + toolCalls += 1; + } else if (obj.type === 'result') { + const u = obj.usage ?? obj.stats ?? {}; + input += u.input_token_count ?? u.input_tokens ?? u.prompt_tokens ?? 0; + out += u.output_token_count ?? u.output_tokens ?? u.completion_tokens ?? 0; + if (typeof obj.model === 'string' && obj.model) modelUsed = obj.model; + } + } catch { + // skip malformed lines + } + } + return { output, tokens: { input, output: out }, toolCalls, modelUsed }; +} + +/** + * Map a raw stream-json dump to a RunResult, including the empty-success + * hardening from #2159. Exported so adapter e2e can exercise the full + * post-CLI path without a live gemini binary. + */ +export function resultFromGeminiStream( + raw: string, + opts: { model?: string; durationMs?: number } = {}, +): RunResult { + const parsed = parseGeminiStreamJson(raw); + const modelUsed = parsed.modelUsed || opts.model || 'gemini-2.5-pro'; + const durationMs = opts.durationMs ?? 0; + if (!parsed.output.trim()) { + return { + output: '', + tokens: { input: 0, output: 0 }, + durationMs, + toolCalls: 0, + modelUsed, + error: { code: 'unknown', reason: 'empty output from gemini CLI (exit 0)' }, + }; + } + return { + output: parsed.output, + tokens: parsed.tokens, + durationMs, + toolCalls: parsed.toolCalls, + modelUsed, + }; +} + /** * Gemini adapter — wraps the `gemini` CLI. * - * Gemini CLI auth comes from either ~/.config/gemini/ or GOOGLE_API_KEY. Output - * format is NDJSON with `message`/`tool_use`/`result` events when `--output-format - * stream-json` is requested. This adapter uses a single-response form for simplicity - * in benchmarks; richer streaming lives in gemini-session-runner.ts. + * Auth: GEMINI_API_KEY / GOOGLE_API_KEY (preferred), or ~/.gemini oauth. + * Personal OAuth free-tier is no longer supported by gemini CLI — use an + * AI Studio API key. Antigravity is a separate product/quota path. + * + * Headless flags always passed: + * --output-format stream-json — NDJSON events (message/tool_use/result) + * --yolo — auto-approve tools (non-interactive) + * --skip-trust — trust cwd for this session; required when + * workdir is a temp/untrusted folder (benchmarks + * use mkdtemp). Without it headless gemini exits + * before calling the model. */ export class GeminiAdapter implements ProviderAdapter { readonly name = 'gemini'; @@ -26,9 +121,14 @@ export class GeminiAdapter implements ProviderAdapter { const newCfgDir = path.join(os.homedir(), '.gemini'); const newOauth = path.join(newCfgDir, 'oauth_creds.json'); const hasCfg = fs.existsSync(legacyCfgDir) || fs.existsSync(newOauth); - const hasKey = !!process.env.GOOGLE_API_KEY; + // CLI accepts either name; Google AI Studio keys are usually GEMINI_API_KEY. + const hasKey = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY); if (!hasCfg && !hasKey) { - return { ok: false, reason: 'No Gemini auth found. Log in via `gemini login` or export GOOGLE_API_KEY.' }; + return { + ok: false, + reason: + 'No Gemini auth found. Export GEMINI_API_KEY (or GOOGLE_API_KEY) from https://aistudio.google.com/app/apikey — personal OAuth free-tier is no longer supported by gemini CLI.', + }; } return { ok: true }; } @@ -36,8 +136,10 @@ export class GeminiAdapter implements ProviderAdapter { async run(opts: RunOpts): Promise { const start = Date.now(); // Default to --yolo (non-interactive) and stream-json output so we can parse - // tokens + tool calls. Callers can override via extraArgs. - const args = ['-p', opts.prompt, '--output-format', 'stream-json', '--yolo']; + // tokens + tool calls. --skip-trust is required for headless/temp workdirs + // (gemini CLI otherwise exits: "not running in a trusted directory"). + // Callers can override via extraArgs. + const args = ['-p', opts.prompt, '--output-format', 'stream-json', '--yolo', '--skip-trust']; if (opts.model) args.push('--model', opts.model); if (opts.extraArgs) args.push(...opts.extraArgs); @@ -47,15 +149,15 @@ export class GeminiAdapter implements ProviderAdapter { timeout: opts.timeoutMs, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024, + env: { + ...process.env, + // Prefer GEMINI_API_KEY when only that is set (CLI reads both). + ...(process.env.GEMINI_API_KEY && !process.env.GOOGLE_API_KEY + ? { GOOGLE_API_KEY: process.env.GEMINI_API_KEY } + : {}), + }, }); - const parsed = this.parseStreamJson(out); - return { - output: parsed.output, - tokens: parsed.tokens, - durationMs: Date.now() - start, - toolCalls: parsed.toolCalls, - modelUsed: parsed.modelUsed || opts.model || 'gemini-2.5-pro', - }; + return resultFromGeminiStream(out, { model: opts.model, durationMs: Date.now() - start }); } catch (err: unknown) { const durationMs = Date.now() - start; const e = err as { code?: string; stderr?: Buffer; signal?: string; message?: string }; @@ -63,7 +165,7 @@ export class GeminiAdapter implements ProviderAdapter { if (e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT') { return this.emptyResult(durationMs, { code: 'timeout', reason: `exceeded ${opts.timeoutMs}ms` }, opts.model); } - if (/unauthorized|auth|login|api key/i.test(stderr)) { + if (/unauthorized|auth|login|api key|ineligibletier|no longer supported/i.test(stderr)) { return this.emptyResult(durationMs, { code: 'auth', reason: stderr.slice(0, 400) }, opts.model); } if (/rate[- ]?limit|429|quota/i.test(stderr)) { @@ -77,41 +179,6 @@ export class GeminiAdapter implements ProviderAdapter { return estimateCostUsd(tokens, model ?? 'gemini-2.5-pro'); } - /** - * Parse gemini NDJSON stream events: - * init → session id (discarded here) - * message { delta: true, text } → concat to output - * tool_use { name } → increment toolCalls - * result { usage: { input_token_count, output_token_count } } → tokens - */ - private parseStreamJson(raw: string): { output: string; tokens: { input: number; output: number }; toolCalls: number; modelUsed?: string } { - let output = ''; - let input = 0; - let out = 0; - let toolCalls = 0; - let modelUsed: string | undefined; - for (const line of raw.split('\n')) { - const s = line.trim(); - if (!s) continue; - try { - const obj = JSON.parse(s); - if (obj.type === 'message' && typeof obj.text === 'string') { - output += obj.text; - } else if (obj.type === 'tool_use') { - toolCalls += 1; - } else if (obj.type === 'result') { - const u = obj.usage ?? {}; - input += u.input_token_count ?? u.prompt_tokens ?? 0; - out += u.output_token_count ?? u.completion_tokens ?? 0; - if (obj.model) modelUsed = obj.model; - } - } catch { - // skip malformed lines - } - } - return { output, tokens: { input, output: out }, toolCalls, modelUsed }; - } - private emptyResult(durationMs: number, error: RunResult['error'], model?: string): RunResult { return { output: '', diff --git a/test/skill-e2e-benchmark-providers.test.ts b/test/skill-e2e-benchmark-providers.test.ts index 12456ec23..98c2f3e03 100644 --- a/test/skill-e2e-benchmark-providers.test.ts +++ b/test/skill-e2e-benchmark-providers.test.ts @@ -129,19 +129,15 @@ describeIfEvals('multi-provider benchmark adapters (live)', () => { if (result.error) { throw new Error(`gemini errored: ${result.error.code} — ${result.error.reason}`); } - // Gemini CLI occasionally returns empty output even on successful runs - // (model returned content the CLI parser missed, intermittent stream issues). - // We assert the adapter ran end-to-end without erroring and reports a non- - // empty token count instead of grepping the literal "ok" — that string - // assertion was too brittle for a smoke that's really about "did the - // adapter wire up and the run terminate successfully?" - expect(typeof result.output).toBe('string'); - // Gemini CLI sometimes returns 0 tokens in the result event (older responses); - // assert non-negative instead of strictly positive. - expect(result.tokens.input).toBeGreaterThanOrEqual(0); - expect(result.tokens.output).toBeGreaterThanOrEqual(0); + // Adapter must never report empty-success (#2159). After content/stats + // parsing, a healthy run has non-empty assistant text + token counts. + expect(result.output.trim().length).toBeGreaterThan(0); + expect(result.output.toLowerCase()).toContain('ok'); + expect(result.tokens.input).toBeGreaterThan(0); + expect(result.tokens.output).toBeGreaterThan(0); expect(result.durationMs).toBeGreaterThan(0); expect(typeof result.modelUsed).toBe('string'); + expect(result.modelUsed.length).toBeGreaterThan(0); }, 150_000); test('timeout error surfaces as error.code=timeout (no exception)', async () => { diff --git a/test/user-slug-fallback.test.ts b/test/user-slug-fallback.test.ts index 1d8c3f925..0c05f560e 100644 --- a/test/user-slug-fallback.test.ts +++ b/test/user-slug-fallback.test.ts @@ -8,7 +8,7 @@ * 3. sha8($(git config user.email)) * 4. anonymous- * - * Result is persisted under user_slug_at_ for stability. + * Result is persisted under user_slug_at_ for stability. * Test isolation via GSTACK_HOME and HOME env overrides. * * Gate-tier, free, ~50ms. @@ -87,12 +87,12 @@ describe('resolve-user-slug fallback chain', () => { expect(slug).toMatch(/^(email-|anonymous-)[a-f0-9]+$|^[a-zA-Z0-9-]+$/); }); - test('persists resolution to user_slug_at_ on first call', () => { + test('persists resolution to user_slug_at_ on first call', () => { runConfig(['resolve-user-slug'], { GSTACK_HOME: TMP_HOME, USER: 'persisttest' }); const configFile = join(TMP_HOME, 'config.yaml'); expect(existsSync(configFile)).toBe(true); const content = readFileSync(configFile, 'utf-8'); - expect(content).toMatch(/^user_slug_at_[a-f0-9]+:\s+persisttest/m); + expect(content).toMatch(/^user_slug_at_(local|[a-f0-9]{8}|[a-f0-9]{16}):\s+persisttest/m); }); test('subsequent calls return same slug (stable across sessions)', () => { @@ -104,7 +104,7 @@ describe('resolve-user-slug fallback chain', () => { }); }); -describe('brain_trust_policy@ namespace', () => { +describe('brain_trust_policy@ namespace', () => { test('default value is "unset"', () => { const result = runConfig(['get', 'brain_trust_policy@deadbeef'], { GSTACK_HOME: TMP_HOME }); expect(result.status).toBe(0); @@ -158,4 +158,10 @@ describe('key validation', () => { const result = runConfig(['get', 'brain_trust_policy@abc123ff'], { GSTACK_HOME: TMP_HOME }); expect(result.status).toBe(0); }); + + test('accepts @local suffix on key', () => { + const result = runConfig(['get', 'brain_trust_policy@local'], { GSTACK_HOME: TMP_HOME }); + expect(result.status).toBe(0); + expect(result.stdout).toBe('unset'); + }); });