mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-02 00:01:37 +02:00
v1.55.0.0 fix wave: gbrain data-loss guards + browser crash-loop + 6 more (#1808)
* fix(jsonl-merge): make equal-ts resolution converge across machines The JSONL append merge driver sorted timestamped entries by (0, ts) with no further tiebreaker. Equal-ts entries then fell back to stable-sort insertion order (base, ours, theirs), but git assigns the local side to "ours", so two machines resolving the same conflict emitted equal-ts lines in opposite order. The merged files diverged and never converged. gstack-telemetry-log uses second-granularity timestamps, so same-ts collisions are routine. Add the line content as the final sort tiebreaker so the order is total and side-independent. Add a regression test that runs the driver with the two sides swapped and asserts identical output. * fix(gen-skill-docs): quote frontmatter descriptions with interior colons (#1778) Generated SKILL.md frontmatter emitted the catalog-trimmed description: as a plain YAML scalar. A description with an interior ": " (e.g. "Ship workflow: detect...") parses as a nested mapping under strict YAML loaders, so Codex/OpenAI skill loading rejected those skills. applyCatalogTrim now routes the value through toYamlInlineScalar, which quotes (via JSON.stringify) only when a plain scalar would be invalid — interior ": ", inline " #", leading indicator char, or surrounding whitespace. Strings that are already valid plain scalars pass through unchanged to keep regen diffs small. The frontmatter test now parses every generated block (Claude + Codex hosts) with Bun.YAML.parse instead of string-checking that name:/description: substrings exist, so the regression can't reappear. Runs under `bun test` (already in CI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(skills): regenerate SKILL.md after frontmatter quoting fix (#1778) 9 catalog-trimmed descriptions whose values contain an interior colon or inline- comment marker are now quoted. Generated output only; rerun of bun run gen:skill-docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(gbrain-sources): centralize sources-list shape handling in parseSourcesList (#1576) #1576's crash in sourceLocalPath was already fixed in v1.42.0.0 (dual-shape handling). But the readers disagreed: sourceLocalPath accepted both the wrapped {sources:[...]} object (v0.20+) and a bare array, while probeSource and sourcePageCount accepted only the wrapped shape. Extract one parseSourcesList() normalizer and route all three through it, so the shape assumption lives in a single place. This is also the base the #1734 remote_url audit builds on. parseSourcesList returns [] for null/garbage rather than throwing; callers treat 'no rows' as absent. New test/gbrain-sources-parse.test.ts pins both shapes plus the garbage paths and confirms config.remote_url survives for the audit. #1576 is closeable as already-fixed in v1.42.0.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(gbrain): spawn gbrain + brain-sync through a shell on Windows (#1731) On Windows, bun/npm install gbrain as a gbrain.cmd/.ps1 shim and gstack-brain-sync is a bash shebang script. spawnSync/spawn/execFileSync resolve neither without a shell, so the child spawn failed ENOENT — on the sync orchestrator this surfaced as 'brain-sync exited undefined' (#1731). Add NEEDS_SHELL_ON_WINDOWS (process.platform === 'win32') in gbrain-exec and pass it as shell: to every gbrain/brain-sync child spawn: spawnGbrain, spawnGbrainAsync, execGbrainText (gbrain-exec), the two sources-list/remove/add spawns (gbrain-sources), the version + probe spawns (gbrain-local-status), and the two brain-sync spawns in the orchestrator. POSIX keeps the cheaper no-shell path. macOS/Linux CI can't exercise the Windows path, so test/gbrain-spawn-windows-shell.ts is a static-grep tripwire: it fails CI if a gbrain/brain-sync spawn is added without the shell flag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(catalog-trim): expect YAML-quoted descriptions with interior colons (#1778) The quoting fix wraps colon-bearing catalog descriptions in double quotes; two catalog-trim assertions still pinned the old unquoted form. Tolerate the optional quotes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(gbrain-sync): defensive guards against destructive gbrain ops (#1734) The orchestrator shelled out to gbrain's destructive subcommands as if they were safe. gbrain can rm-rf a user's working tree during an autopilot race (its own bug, upstream gbrain #1526); gstack now defends itself. New lib/gbrain-guards.ts gates the two destructive reach points, all checked immediately before the op: - Autopilot refuse (multi-signal, affirmative-only): refuse a destructive op when a live 'gbrain autopilot' process (primary) or a known autopilot lock file (secondary; checked under both GBRAIN_HOME and ~/.gbrain since gbrain #1226 ignores GBRAIN_HOME) is present. No signal → proceed; inability to introspect never bricks a normal sync. - sources remove: routed through safeSourcesRemove → decideSourceRemove. Fail CLOSED — refuse to remove a user-managed source (remote_url set, local_path outside gbrain's clones) when gbrain has no --keep-storage to protect the files (it doesn't in 0.41.x). Also fail closed when the source list can't be read. Path containment uses realpath so a symlink can't smuggle a delete out of clones. - sync --strategy code: decideCodeSync refuses URL-managed sources (remote_url set) unless --allow-reclone is passed, since the walk can auto-reclone (rm-rf). Capability detection memoizes per process keyed to gbrain's identity (no stale persistent cache); --keep-storage can't be probed (generic help) so it defaults unsupported → fail closed. Every guard surfaces a visible reason; autopilot/reclone refusals fail the code stage (verdict ERR) rather than silently skipping protection. test/gbrain-guards.test.ts covers all branches hermetically (injected rows + probe overrides): autopilot signals, fail-closed remove, keep-storage path, reclone gate, realpath/symlink containment. Supersedes #1736 (which guarded a nonexistent path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sync-gbrain): warn against running during autopilot; prefer --path sources (#1734) Adds a Safety note to the /sync-gbrain guidance (template + regenerated SKILL.md + this repo's CLAUDE.md): don't run while autopilot is active, and prefer `gbrain sources add --path` over URL-managed sources, which can auto-reclone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(memory-ingest): configurable import timeout + resume-on-timeout messaging (#1611) The gbrain import (the long pole on big brains) had a hardcoded 30-min timeout, so large memory corpora got SIGTERM'd mid-import on /sync-gbrain --full. Make it configurable via GSTACK_INGEST_TIMEOUT_MS (default 30 min, validated 1min–24h). gstack can't drive gbrain's internal resume, but the existing SIGTERM forwarder already preserves gbrain's import-checkpoint.json, so the next run resumes. On a timeout we now say so explicitly ('checkpoint preserved — re-run /sync-gbrain to resume, raise GSTACK_INGEST_TIMEOUT_MS for big brains') instead of surfacing a bare 'exited null'. True gstack-driven ingest-resume is deferred to gbrain (.context/gbrain-asks.md). Also guards the module's main() behind import.meta.main so resolveImportTimeoutMs is unit-testable; the orchestrator runs it as a subprocess where main still fires. New test/memory-ingest-timeout.test.ts pins default/override/invalid resolution. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(browse): stop the headed daemon crash-loop + silent headless downgrade (#1781) A headed session against a beacon-heavy page (analytics/extension load) could tip the single-threaded daemon into a self-inflicted crash-loop: a brief HTTP stall was read as a crash, the restart didn't clear the dead Chromium's SingletonLock, the relaunch failed, and the session silently came back headless. Four fixes: 1. Busy-vs-dead (sendCommand): on a connection error, if the process is alive give /health a bounded probe (3x/250ms) and just retry the command — never kill+restart a live-but-busy server. A 30s timeout now reports 'busy, not restarting' when the process is alive instead of exiting into a kill cycle. 2. Profile-lock cleanup on (re)start: startServer reaps the orphaned Chromium holding the SingletonLock and clears Singleton{Lock,Socket,Cookie} before relaunch, so the auto-restart path gets the same clean profile the manual connect preamble did. 3. Headed persistence: the restart env reapplies BROWSE_HEADED from this invocation OR the persisted server state (mode==='headed'), so a restart from a plain command never downgrades a headed window to invisible headless. Extracted to buildRestartEnv. 4. Force-clean disconnect reaps the Chromium child tree (via the SingletonLock PID) so the next connect starts clean instead of fighting an orphan. Plus macOS window surfacing: connect + focus raise 'Google Chrome for Testing' to the active Space (best-effort osascript) with a Mission Control hint — the first thing users read as 'I can't see the browser'. Shared lock helpers (chromiumProfileDir / cleanChromiumProfileLocks / killOrphanChromium) dedupe the connect, disconnect, and restart paths. browse/test/restart-env.test.ts pins the headed-persistence decision; the full crash-loop repro is an E2E (periodic). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(gbrain-install): remove the v0.18.2 pin, install latest + version floor + doctor self-test (#1744) The installer pinned gbrain at v0.18.2 while gbrain shipped v0.41.x — ~23 versions behind. Remove the hard pin: a fresh clone now stays on the latest default-branch HEAD. --pinned-commit <sha> still pins for reproducibility. Unpinning removes the version gate the pin provided, so add two install-time gates that fail closed (exit 3, matching the existing PATH-shadow/version-mismatch posture): - MIN_GBRAIN_VERSION floor (0.20.0, the sources-list/federated surface gstack needs): refuse an install below it. - gbrain doctor --fast self-test when a brain config already exists (re-install / detected clone): refuse to leave a broken gbrain in place. Pre-init installs skip it; the full /sync-gbrain --dry-run self-test runs from /setup-gbrain after init. Docs updated (USING_GBRAIN_WITH_GSTACK.md no longer says 'edit PINNED_COMMIT'). Detect-install tests bump the success-path fixtures above the floor and add a below-floor exit-3 test. The gbrain-side asks (root #1526 fix, --keep-storage, remove-lease, capability command, ingest-resume, integration CI) are written to .context/gbrain-asks.md for filing against garrytan/gbrain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(#1778): update claude-ship golden + catalog-mode assertions for quoted descriptions ship's catalog description ('Ship workflow: detect...') has an interior colon, so the #1778 fix now YAML-quotes it. Refresh the claude-ship golden baseline to the quoted output and make the catalog-mode-full trim/restore assertions quote-tolerant. codex/factory ship goldens are unaffected (they use block-scalar descriptions). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(gen-skill-docs): use function replacer so a $ in a description can't corrupt frontmatter (#1778) String.prototype.replace treats $&/$1/$` in the replacement as patterns. A future skill description containing $ (e.g. referencing $B/$D) would silently corrupt the generated frontmatter. Use a function replacer. Behavior-preserving for all current descriptions (regen produces no diff). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v1.55.0.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(gbrain): document configurable memory-ingest timeout for v1.55.0.0 USING_GBRAIN_WITH_GSTACK.md: note GSTACK_INGEST_TIMEOUT_MS (default 30 min, 1 min-24h range) on the /sync-gbrain memory stage, plus checkpoint-resume on timeout. Fills the reference gap left by the configurable-import-timeout fix (#1611) shipped in v1.55.0.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,9 @@ describe('--catalog-mode=full opt-out behavior (smoke)', () => {
|
||||
test('--catalog-mode=full produces multi-line description in frontmatter', () => {
|
||||
// Save the trim'd state so we can restore it.
|
||||
const trimmedShip = fs.readFileSync(SHIP_SKILL, 'utf-8');
|
||||
expect(trimmedShip).toMatch(/^description: Ship workflow:[^\n]*\(gstack\)\n/m);
|
||||
// #1778: the trimmed ship description has an interior colon ("Ship workflow:")
|
||||
// and is now YAML-quoted — tolerate the optional surrounding quotes.
|
||||
expect(trimmedShip).toMatch(/^description: "?Ship workflow:[^\n]*\(gstack\)"?\n/m);
|
||||
|
||||
try {
|
||||
// Run with --catalog-mode=full. Mutates working tree.
|
||||
@@ -100,7 +102,8 @@ describe('--catalog-mode=full opt-out behavior (smoke)', () => {
|
||||
}
|
||||
// Sanity-check the restored state matches what we saw at the start.
|
||||
const restoredShip = fs.readFileSync(SHIP_SKILL, 'utf-8');
|
||||
expect(restoredShip).toMatch(/^description: Ship workflow:[^\n]*\(gstack\)\n/m);
|
||||
// #1778: restored trim state has the YAML-quoted (interior-colon) description.
|
||||
expect(restoredShip).toMatch(/^description: "?Ship workflow:[^\n]*\(gstack\)"?\n/m);
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
|
||||
@@ -227,8 +227,10 @@ Original body content here.
|
||||
const result = applyCatalogTrim(minimalSkill, 'example');
|
||||
expect(result).not.toBeNull();
|
||||
const { content, parts } = result!;
|
||||
// Frontmatter description is now ONE line ending with (gstack)
|
||||
expect(content).toMatch(/^description: Example skill:[^\n]*\(gstack\)\n/m);
|
||||
// Frontmatter description is now ONE line ending with (gstack). #1778: a
|
||||
// description with an interior colon ("Example skill:") is YAML-quoted, so
|
||||
// the value is wrapped in double quotes — tolerate the optional quotes.
|
||||
expect(content).toMatch(/^description: "?Example skill:[^\n]*\(gstack\)"?\n/m);
|
||||
// Body has the When to invoke section
|
||||
expect(content).toContain('## When to invoke this skill');
|
||||
expect(content).toContain('Use when asked to do an example task.');
|
||||
@@ -257,7 +259,8 @@ Original body content here.
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.content).not.toMatch(/\(gstack\)preamble-tier/);
|
||||
expect(result!.content).not.toMatch(/\(gstack\)allowed-tools/);
|
||||
expect(result!.content).toMatch(/\(gstack\)\n[a-z-]+:/);
|
||||
// #1778: optional closing quote when the description was YAML-quoted.
|
||||
expect(result!.content).toMatch(/\(gstack\)"?\n[a-z-]+:/);
|
||||
});
|
||||
|
||||
test('returns null on content without proper frontmatter', () => {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
name: ship
|
||||
preamble-tier: 4
|
||||
version: 1.0.0
|
||||
description: Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. (gstack)
|
||||
description: "Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. (gstack)"
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
|
||||
@@ -204,14 +204,30 @@ describe('gstack-gbrain-install D19 PATH-shadow validation', () => {
|
||||
}
|
||||
|
||||
test('passes when install-dir version matches `gbrain --version` on PATH', () => {
|
||||
// Version must be >= MIN_GBRAIN_VERSION (0.20.0) floor (#1744).
|
||||
const installDir = seedInstallDir('0.41.29');
|
||||
const fakeBin = seedFakeGbrainBinary('0.41.29');
|
||||
try {
|
||||
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
|
||||
env: { PATH: `${fakeBin}:${SAFE_PATH}` },
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('installed gbrain 0.41.29');
|
||||
} finally {
|
||||
fs.rmSync(installDir, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('hard-fails (exit 3) when the installed gbrain is below the version floor (#1744)', () => {
|
||||
const installDir = seedInstallDir('0.18.2');
|
||||
const fakeBin = seedFakeGbrainBinary('0.18.2');
|
||||
try {
|
||||
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
|
||||
env: { PATH: `${fakeBin}:${SAFE_PATH}` },
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('installed gbrain 0.18.2');
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('below the minimum gstack-tested version');
|
||||
} finally {
|
||||
fs.rmSync(installDir, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBin, { recursive: true, force: true });
|
||||
@@ -219,8 +235,8 @@ describe('gstack-gbrain-install D19 PATH-shadow validation', () => {
|
||||
});
|
||||
|
||||
test('tolerates a leading "v" in `gbrain --version` output', () => {
|
||||
const installDir = seedInstallDir('0.18.2');
|
||||
const fakeBin = seedFakeGbrainBinary('v0.18.2');
|
||||
const installDir = seedInstallDir('0.41.29');
|
||||
const fakeBin = seedFakeGbrainBinary('v0.41.29');
|
||||
try {
|
||||
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
|
||||
env: { PATH: `${fakeBin}:${SAFE_PATH}` },
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, test, expect, afterEach } from "bun:test";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import { join } from "path";
|
||||
import {
|
||||
detectAutopilot,
|
||||
decideSourceRemove,
|
||||
decideCodeSync,
|
||||
isInside,
|
||||
_resetCapabilityMemo,
|
||||
type GbrainSourceRow,
|
||||
} from "../lib/gbrain-guards";
|
||||
|
||||
const HOME = os.homedir();
|
||||
const clonesPath = (name: string) => join(HOME, ".gbrain", "clones", name);
|
||||
|
||||
afterEach(() => _resetCapabilityMemo());
|
||||
|
||||
// ── #1734 autopilot detection (E1: affirmative multi-signal) ────────────────
|
||||
describe("detectAutopilot", () => {
|
||||
test("refuses on a present lock file (secondary signal)", () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ap-"));
|
||||
const lock = join(tmp, "autopilot.lock");
|
||||
fs.writeFileSync(lock, "");
|
||||
const r = detectAutopilot(process.env, { lockPaths: [lock], processRunning: () => false });
|
||||
expect(r.active).toBe(true);
|
||||
expect(r.signal).toContain("lock:");
|
||||
});
|
||||
|
||||
test("refuses on a live autopilot process (primary signal)", () => {
|
||||
const r = detectAutopilot(process.env, { lockPaths: [], processRunning: () => true });
|
||||
expect(r.active).toBe(true);
|
||||
expect(r.signal).toBe("process:gbrain autopilot");
|
||||
});
|
||||
|
||||
test("proceeds when no signal fires (never blanket-refuses)", () => {
|
||||
const r = detectAutopilot(process.env, { lockPaths: [], processRunning: () => false });
|
||||
expect(r.active).toBe(false);
|
||||
expect(r.signal).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── #1734 remove safety (E7: fail closed on user-managed without keep-storage) ─
|
||||
describe("decideSourceRemove", () => {
|
||||
const rows = (extra: GbrainSourceRow[] = []): GbrainSourceRow[] => [
|
||||
{ id: "gbrain-managed", local_path: clonesPath("repo"), config: { remote_url: "https://x/r.git" } },
|
||||
{ id: "user-managed", local_path: "/tmp/user-repo", config: { remote_url: "https://x/r.git" } },
|
||||
{ id: "path-managed", local_path: "/tmp/path-repo" }, // no remote_url
|
||||
...extra,
|
||||
];
|
||||
const fetchRows = (extra?: GbrainSourceRow[]) => () => rows(extra);
|
||||
|
||||
test("absent source → allow (no-op)", () => {
|
||||
const d = decideSourceRemove("nope", process.env, { keepStorage: false, fetchRows: fetchRows() });
|
||||
expect(d.allow).toBe(true);
|
||||
expect(d.reason).toContain("absent");
|
||||
});
|
||||
|
||||
test("user-managed + no --keep-storage → FAIL CLOSED", () => {
|
||||
const d = decideSourceRemove("user-managed", process.env, { keepStorage: false, fetchRows: fetchRows() });
|
||||
expect(d.allow).toBe(false);
|
||||
expect(d.reason).toContain("user-managed");
|
||||
});
|
||||
|
||||
test("user-managed + --keep-storage supported → allow with flag", () => {
|
||||
const d = decideSourceRemove("user-managed", process.env, { keepStorage: true, fetchRows: fetchRows() });
|
||||
expect(d.allow).toBe(true);
|
||||
expect(d.extraArgs).toContain("--keep-storage");
|
||||
});
|
||||
|
||||
test("gbrain-managed (inside clones) → allow even without keep-storage", () => {
|
||||
const d = decideSourceRemove("gbrain-managed", process.env, { keepStorage: false, fetchRows: fetchRows() });
|
||||
expect(d.allow).toBe(true);
|
||||
});
|
||||
|
||||
test("path-managed without remote_url → allow (normal --path case)", () => {
|
||||
const d = decideSourceRemove("path-managed", process.env, { keepStorage: false, fetchRows: fetchRows() });
|
||||
expect(d.allow).toBe(true);
|
||||
});
|
||||
|
||||
test("sources unreadable → FAIL CLOSED", () => {
|
||||
const d = decideSourceRemove("user-managed", process.env, {
|
||||
keepStorage: false,
|
||||
fetchRows: () => { throw new Error("boom"); },
|
||||
});
|
||||
expect(d.allow).toBe(false);
|
||||
expect(d.reason).toContain("fail closed");
|
||||
});
|
||||
});
|
||||
|
||||
// ── #1734 reclone guard (E-level: require --allow-reclone for URL-managed) ───
|
||||
describe("decideCodeSync", () => {
|
||||
const rows: GbrainSourceRow[] = [
|
||||
{ id: "url-managed", local_path: "/tmp/u", config: { remote_url: "https://x/r.git" } },
|
||||
{ id: "plain", local_path: "/tmp/p" },
|
||||
];
|
||||
const fetch = () => rows;
|
||||
|
||||
test("URL-managed + no --allow-reclone → refuse", () => {
|
||||
const d = decideCodeSync("url-managed", process.env, false, fetch);
|
||||
expect(d.allow).toBe(false);
|
||||
expect(d.reason).toContain("auto-reclone");
|
||||
});
|
||||
|
||||
test("URL-managed + --allow-reclone → allow", () => {
|
||||
const d = decideCodeSync("url-managed", process.env, true, fetch);
|
||||
expect(d.allow).toBe(true);
|
||||
});
|
||||
|
||||
test("no remote_url → allow", () => {
|
||||
const d = decideCodeSync("plain", process.env, false, fetch);
|
||||
expect(d.allow).toBe(true);
|
||||
});
|
||||
|
||||
test("sources unreadable → fail OPEN (sync read is non-destructive)", () => {
|
||||
const d = decideCodeSync("url-managed", process.env, false, () => { throw new Error("boom"); });
|
||||
expect(d.allow).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── path containment uses realpath (symlink can't smuggle a delete out) ──────
|
||||
describe("isInside", () => {
|
||||
test("plain path inside dir", () => {
|
||||
expect(isInside("/a/b/c", "/a/b")).toBe(true);
|
||||
expect(isInside("/a/x", "/a/b")).toBe(false);
|
||||
});
|
||||
|
||||
test("sibling-prefix is not 'inside' (clonesX vs clones)", () => {
|
||||
expect(isInside("/a/clones-evil/x", "/a/clones")).toBe(false);
|
||||
});
|
||||
|
||||
test("symlink pointing outside resolves outside", () => {
|
||||
const base = fs.mkdtempSync(join(os.tmpdir(), "clones-"));
|
||||
const outside = fs.mkdtempSync(join(os.tmpdir(), "outside-"));
|
||||
const link = join(base, "sneaky");
|
||||
fs.symlinkSync(outside, link);
|
||||
// link lives under base, but realpath resolves to `outside` → not inside base.
|
||||
expect(isInside(link, base)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { parseSourcesList } from "../lib/gbrain-sources";
|
||||
|
||||
// #1576 hardening: `gbrain sources list --json` has shipped two shapes — a
|
||||
// wrapped `{ sources: [...] }` object (v0.20+) and a bare top-level array.
|
||||
// parseSourcesList is the single place that normalizes both, so every reader
|
||||
// (probeSource, sourcePageCount, sourceLocalPath, the #1734 remote_url audit)
|
||||
// agrees on the shape. These tests pin both shapes plus the garbage paths.
|
||||
describe("parseSourcesList", () => {
|
||||
const rows = [
|
||||
{ id: "a", local_path: "/x", page_count: 3 },
|
||||
{ id: "b", local_path: "/y", config: { remote_url: "https://example.com/r.git" } },
|
||||
];
|
||||
|
||||
test("wrapped { sources: [...] } shape", () => {
|
||||
expect(parseSourcesList({ sources: rows })).toEqual(rows);
|
||||
});
|
||||
|
||||
test("bare top-level array shape", () => {
|
||||
expect(parseSourcesList(rows)).toEqual(rows);
|
||||
});
|
||||
|
||||
test("both shapes yield identical rows (shape-independent)", () => {
|
||||
expect(parseSourcesList({ sources: rows })).toEqual(parseSourcesList(rows));
|
||||
});
|
||||
|
||||
test("null / undefined → empty array (no throw)", () => {
|
||||
expect(parseSourcesList(null)).toEqual([]);
|
||||
expect(parseSourcesList(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
test("object without sources key → empty array", () => {
|
||||
expect(parseSourcesList({ pages: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
test("sources key present but not an array → empty array", () => {
|
||||
expect(parseSourcesList({ sources: "oops" })).toEqual([]);
|
||||
});
|
||||
|
||||
test("scalar garbage → empty array", () => {
|
||||
expect(parseSourcesList("nope")).toEqual([]);
|
||||
expect(parseSourcesList(42)).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves config.remote_url for the #1734 audit", () => {
|
||||
const parsed = parseSourcesList({ sources: rows });
|
||||
expect(parsed.find((r) => r.id === "b")?.config?.remote_url).toBe("https://example.com/r.git");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8");
|
||||
|
||||
// #1731 tripwire. Windows can't spawn the `gbrain` shim (gbrain.cmd) or the bash
|
||||
// shebang script gstack-brain-sync without a shell; the fix gates `shell: true`
|
||||
// behind NEEDS_SHELL_ON_WINDOWS. These static checks fail CI if a refactor adds
|
||||
// a gbrain/brain-sync child spawn without the Windows shell flag, since macOS/
|
||||
// Linux CI can't exercise the Windows path at runtime.
|
||||
describe("#1731 gbrain spawns carry the Windows shell flag", () => {
|
||||
test("NEEDS_SHELL_ON_WINDOWS is platform-gated in gbrain-exec.ts", () => {
|
||||
const src = read("lib/gbrain-exec.ts");
|
||||
expect(src).toMatch(/export const NEEDS_SHELL_ON_WINDOWS\s*=\s*process\.platform === "win32"/);
|
||||
});
|
||||
|
||||
// Every direct `gbrain` child spawn in these files must be matched by a
|
||||
// shell:NEEDS_SHELL_ON_WINDOWS flag. Count openers vs flags as a cheap,
|
||||
// refactor-resistant invariant.
|
||||
const gbrainSpawnFiles = [
|
||||
"lib/gbrain-exec.ts",
|
||||
"lib/gbrain-sources.ts",
|
||||
"lib/gbrain-local-status.ts",
|
||||
];
|
||||
for (const rel of gbrainSpawnFiles) {
|
||||
test(`${rel}: every gbrain spawn has shell:NEEDS_SHELL_ON_WINDOWS`, () => {
|
||||
const src = read(rel);
|
||||
const spawnOpeners = src.match(/(spawnSync|spawn|execFileSync)\("gbrain"/g)?.length ?? 0;
|
||||
const shellFlags = src.match(/shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0;
|
||||
expect(spawnOpeners).toBeGreaterThan(0);
|
||||
expect(shellFlags).toBeGreaterThanOrEqual(spawnOpeners);
|
||||
});
|
||||
}
|
||||
|
||||
test("orchestrator brain-sync spawns carry the Windows shell flag", () => {
|
||||
const src = read("bin/gstack-gbrain-sync.ts");
|
||||
const brainSyncSpawns = src.match(/spawnSync\(brainSyncPath,/g)?.length ?? 0;
|
||||
expect(brainSyncSpawns).toBe(2);
|
||||
// Both spawnSync(brainSyncPath, ...) blocks must include the shell flag.
|
||||
const withShell = src.match(/spawnSync\(brainSyncPath,[\s\S]*?shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0;
|
||||
expect(withShell).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -173,12 +173,39 @@ describe('gen-skill-docs', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('every generated SKILL.md has valid YAML frontmatter', () => {
|
||||
// #1778: strict YAML parsers (Codex/OpenAI skill loading) reject frontmatter
|
||||
// whose plain `description:` scalar contains an interior ": " (read as a nested
|
||||
// mapping). Parse EVERY generated frontmatter block with a strict YAML parser,
|
||||
// not just string-check that name:/description: exist.
|
||||
function frontmatterBlock(content: string): string {
|
||||
expect(content.startsWith('---\n')).toBe(true);
|
||||
const end = content.indexOf('\n---', 4);
|
||||
expect(end).toBeGreaterThan(0);
|
||||
return content.slice(4, end);
|
||||
}
|
||||
|
||||
test('every generated SKILL.md frontmatter parses as strict YAML', () => {
|
||||
for (const skill of CLAUDE_GENERATED_SKILLS) {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
||||
expect(content.startsWith('---\n')).toBe(true);
|
||||
expect(content).toContain('name:');
|
||||
expect(content).toContain('description:');
|
||||
const fm = frontmatterBlock(content);
|
||||
let parsed: any;
|
||||
expect(() => { parsed = Bun.YAML.parse(fm); },
|
||||
`frontmatter for ${skill.dir} must be valid YAML`).not.toThrow();
|
||||
expect(typeof parsed?.name).toBe('string');
|
||||
expect(typeof parsed?.description).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
test('every generated Codex (.agents/skills) frontmatter parses as strict YAML', () => {
|
||||
const agentsDir = path.join(ROOT, '.agents', 'skills');
|
||||
if (!fs.existsSync(agentsDir)) return; // skip if external hosts not generated
|
||||
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const mdPath = path.join(agentsDir, entry.name, 'SKILL.md');
|
||||
if (!fs.existsSync(mdPath)) continue;
|
||||
const fm = frontmatterBlock(fs.readFileSync(mdPath, 'utf-8'));
|
||||
expect(() => Bun.YAML.parse(fm),
|
||||
`Codex frontmatter for ${entry.name} must be valid YAML`).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const DRIVER = path.join(ROOT, 'bin', 'gstack-jsonl-merge');
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-jsonl-merge-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Run the merge driver the way git does: `driver <base> <ours> <theirs>`.
|
||||
* The driver writes the merged result back to the <ours> file. Returns that
|
||||
* file's content. `base`/`ours`/`theirs` are arrays of JSONL lines (the file
|
||||
* is created from them); pass `null` to omit a file entirely (git passes an
|
||||
* absent path for an added file, which the driver must tolerate).
|
||||
*/
|
||||
function runMerge(
|
||||
base: string[] | null,
|
||||
ours: string[] | null,
|
||||
theirs: string[] | null,
|
||||
): string {
|
||||
const write = (name: string, lines: string[] | null): string => {
|
||||
const p = path.join(tmpDir, name);
|
||||
if (lines === null) return path.join(tmpDir, `${name}.absent`);
|
||||
fs.writeFileSync(p, lines.length ? lines.join('\n') + '\n' : '');
|
||||
return p;
|
||||
};
|
||||
const basePath = write('base', base);
|
||||
const oursPath = write('ours', ours);
|
||||
const theirsPath = write('theirs', theirs);
|
||||
execFileSync(DRIVER, [basePath, oursPath, theirsPath], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
});
|
||||
return fs.readFileSync(oursPath, 'utf-8');
|
||||
}
|
||||
|
||||
describe('gstack-jsonl-merge', () => {
|
||||
test('equal-ts entries resolve identically regardless of side (convergence)', () => {
|
||||
// Two machines append a different event in the same second, then each
|
||||
// merges the other's push. Machine A sees its own line as "ours"; machine
|
||||
// B sees the same line as "theirs". The merge must produce the same file
|
||||
// on both, or the repos diverge and never reconcile.
|
||||
const a = '{"ts":"2026-05-28T10:00:00Z","event":"a"}';
|
||||
const b = '{"ts":"2026-05-28T10:00:00Z","event":"b"}';
|
||||
|
||||
const machineA = runMerge([], [a], [b]); // a = ours, b = theirs
|
||||
const machineB = runMerge([], [b], [a]); // b = ours, a = theirs
|
||||
|
||||
expect(machineA).toBe(machineB);
|
||||
// Both lines survive.
|
||||
expect(machineA).toContain('"event":"a"');
|
||||
expect(machineA).toContain('"event":"b"');
|
||||
});
|
||||
|
||||
test('non-timestamped lines also resolve identically regardless of side', () => {
|
||||
const a = '{"event":"a"}'; // no ts -> hash-ordered
|
||||
const b = '{"event":"b"}';
|
||||
expect(runMerge([], [a], [b])).toBe(runMerge([], [b], [a]));
|
||||
});
|
||||
|
||||
test('plain (non-JSON) lines resolve identically regardless of side', () => {
|
||||
expect(runMerge([], ['zebra'], ['apple'])).toBe(
|
||||
runMerge([], ['apple'], ['zebra']),
|
||||
);
|
||||
});
|
||||
|
||||
test('exact-duplicate lines are deduped', () => {
|
||||
const line = '{"ts":"2026-05-28T10:00:00Z","event":"a"}';
|
||||
const out = runMerge([line], [line], [line]);
|
||||
expect(out.trimEnd().split('\n')).toEqual([line]);
|
||||
});
|
||||
|
||||
test('timestamped entries sort ascending by ts', () => {
|
||||
const early = '{"ts":"2026-05-28T09:00:00Z","event":"early"}';
|
||||
const late = '{"ts":"2026-05-28T11:00:00Z","event":"late"}';
|
||||
const out = runMerge([], [late], [early]).trimEnd().split('\n');
|
||||
expect(out).toEqual([early, late]);
|
||||
});
|
||||
|
||||
test('absent ours/theirs files are tolerated (added-file merge)', () => {
|
||||
const a = '{"ts":"2026-05-28T10:00:00Z","event":"a"}';
|
||||
const out = runMerge(null, [a], null);
|
||||
expect(out.trimEnd()).toBe(a);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { resolveImportTimeoutMs } from "../bin/gstack-memory-ingest";
|
||||
|
||||
// #1611: the gbrain import timeout is configurable via GSTACK_INGEST_TIMEOUT_MS
|
||||
// (default 30 min) so big-brain --full ingests aren't SIGTERM'd mid-import.
|
||||
const DEFAULT = 30 * 60 * 1000;
|
||||
|
||||
describe("resolveImportTimeoutMs", () => {
|
||||
test("unset → 30 min default", () => {
|
||||
expect(resolveImportTimeoutMs(undefined)).toBe(DEFAULT);
|
||||
expect(resolveImportTimeoutMs("")).toBe(DEFAULT);
|
||||
});
|
||||
|
||||
test("valid override is honored", () => {
|
||||
expect(resolveImportTimeoutMs("3600000")).toBe(3_600_000); // 1h
|
||||
expect(resolveImportTimeoutMs("60000")).toBe(60_000); // floor
|
||||
expect(resolveImportTimeoutMs("86400000")).toBe(86_400_000); // ceiling
|
||||
});
|
||||
|
||||
test("invalid / out-of-range → default (no SIGTERM-too-soon footgun)", () => {
|
||||
expect(resolveImportTimeoutMs("nope")).toBe(DEFAULT);
|
||||
expect(resolveImportTimeoutMs("0")).toBe(DEFAULT);
|
||||
expect(resolveImportTimeoutMs("59999")).toBe(DEFAULT); // below 1min floor
|
||||
expect(resolveImportTimeoutMs("86400001")).toBe(DEFAULT); // above 24h ceiling
|
||||
expect(resolveImportTimeoutMs("-5")).toBe(DEFAULT);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user