mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
Merge origin/main (v1.65.0.0 fork port wave 2) into test-evals-ci-speedup
Second overlapping-wave merge; resolutions compose intent: - TEST_ROOTS: ours is the superset (main also wired ios-qa/daemon/test; ours additionally has ios-qa/scripts + browser-skills). package.json 'test' keeps routing through the canonical strict runner. - gbrainAvailable: main fixed the same load-flake with a strictly better mechanism (memoized stat-based PATH scan, no subprocess at all) — theirs supersedes this branch's memoized-exec probe. Main also made the query timeout env-overridable (GSTACK_BRAIN_TIMEOUT_MS). - Model defaults: adopted main's lib/eval-model.ts abstraction (one resolution point, env-overridable per kind) and applied decision D1a inside it: capture defaults to Sonnet (Opus opt-in via explicit arg or GSTACK_EVAL_MODEL_CAPTURE); test pins updated to follow. - Parent watchdog: main's rewrite (named parameterized tick, driven deterministically by its test via __testInternals__, plus handoff suppression semantics from session persistence) supersedes this branch's env-tunable interval; adopted their server + test wholesale. - windows-free-tests: ours (curated bun run test:windows) — main's hand-list grew by one more file, which the curated runner subsumes automatically; that drift is the reason for D11. - context-skills 0-for-26 fix: both waves made the IDENTICAL fix; kept this branch's comment (carries the receipts). - .gitignore: main's superset (also ignores Package.resolved — their never-commit call; untracked the copy this branch had committed). Verified: 239-test merge battery green, watchdog 8/8, eval-model 5/5, actionlint clean, eval:select works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -109,12 +109,6 @@ describe('Audit compliance', () => {
|
||||
});
|
||||
|
||||
// Round 2 Fix 4: Chrome CDP binds to localhost only
|
||||
test('chrome-cdp binds to localhost only', () => {
|
||||
const cdp = readFileSync(join(ROOT, 'bin/chrome-cdp'), 'utf-8');
|
||||
expect(cdp).toContain('--remote-debugging-address=127.0.0.1');
|
||||
expect(cdp).toContain('--remote-allow-origins=');
|
||||
});
|
||||
|
||||
// Fix 2+6: All generated SKILL.md files with telemetry are conditional
|
||||
test('all generated SKILL.md files with telemetry calls use conditional pattern', () => {
|
||||
const skills = getAllSkillMds();
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Free unit tests for the ClaudeAdapter auth sniff in
|
||||
* test/helpers/providers/claude.ts — specifically the macOS Keychain branch
|
||||
* (#1890): the default subscription install stores OAuth under the
|
||||
* generic-password service "Claude Code-credentials" and never writes
|
||||
* ~/.claude/.credentials.json, so availability must consult
|
||||
* `security find-generic-password -s "Claude Code-credentials"` when neither
|
||||
* the creds file nor ANTHROPIC_API_KEY exists.
|
||||
*
|
||||
* The `security` spawn is darwin-gated in the adapter (the probe only runs
|
||||
* when process.platform === 'darwin'), so the keychain-branch tests skip
|
||||
* elsewhere; the creds-file / env-key / no-auth verdicts run on every
|
||||
* platform.
|
||||
*
|
||||
* Harness note: available() spawns `security` without an explicit `env:`, and
|
||||
* Bun resolves such spawns against the PROCESS STARTUP env — mutating
|
||||
* process.env.PATH in-test cannot shadow the real /usr/bin/security (verified;
|
||||
* same Bun property lib/gbrain-exec.ts documents for DATABASE_URL). So each
|
||||
* case runs available() in a child `bun -e` whose env (and PATH shim) the test
|
||||
* fully controls; the fake `security` logs its argv, so the operator's real
|
||||
* Keychain is never consulted and a logged-in machine can't false-pass the
|
||||
* "no auth" cases. cwd is the fake home so Bun doesn't autoload the repo .env
|
||||
* (which defines ANTHROPIC_API_KEY).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { spawnSync } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
|
||||
const ADAPTER = path.join(import.meta.dir, "helpers", "providers", "claude.ts");
|
||||
|
||||
describe("ClaudeAdapter.available() — auth sniff incl. macOS Keychain branch (#1890)", () => {
|
||||
let fakeHome: string;
|
||||
let shimDir: string;
|
||||
let securityLog: string;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "claude-auth-home-"));
|
||||
shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-auth-bin-"));
|
||||
securityLog = path.join(shimDir, "security-argv.log");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
fs.rmSync(shimDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Fake `security` that logs its argv and exits with `exitCode`. */
|
||||
function writeFakeSecurity(exitCode: number): void {
|
||||
fs.writeFileSync(
|
||||
path.join(shimDir, "security"),
|
||||
`#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "${securityLog}"
|
||||
exit ${exitCode}
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Run `new ClaudeAdapter().available()` in a child bun with a controlled env. */
|
||||
function runAvailable(opts: { anthropicKey?: string; bareShimPath?: boolean } = {}): { ok: boolean; reason?: string } {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
// Shim dir first so a fake `security` shadows /usr/bin/security; the
|
||||
// bare variant drops the inherited PATH entirely (no `claude` findable).
|
||||
PATH: opts.bareShimPath ? shimDir : `${shimDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
// os.homedir() honors $HOME (POSIX) / %USERPROFILE% (Windows), so the
|
||||
// adapter's ~/.claude/.credentials.json check reads the fake home.
|
||||
HOME: fakeHome,
|
||||
USERPROFILE: fakeHome,
|
||||
// Absolute-path override satisfies resolveClaudeCommand() without a real
|
||||
// claude install — available() never spawns it, only resolves it. The
|
||||
// bare-PATH case clears it to exercise the not-found branch.
|
||||
GSTACK_CLAUDE_BIN: opts.bareShimPath ? undefined : path.join(shimDir, "claude"),
|
||||
CLAUDE_BIN: undefined,
|
||||
GSTACK_CLAUDE_BIN_ARGS: undefined,
|
||||
CLAUDE_BIN_ARGS: undefined,
|
||||
ANTHROPIC_API_KEY: opts.anthropicKey,
|
||||
};
|
||||
for (const k of Object.keys(env)) if (env[k] === undefined) delete env[k];
|
||||
const driver = `const { ClaudeAdapter } = await import(${JSON.stringify(ADAPTER)});
|
||||
const check = await new ClaudeAdapter().available();
|
||||
console.log(JSON.stringify(check));`;
|
||||
// process.execPath (absolute bun binary): the bare-PATH case strips the
|
||||
// inherited PATH, so a name-based "bun" lookup would ENOENT.
|
||||
const res = spawnSync(process.execPath, ["-e", driver], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
cwd: fakeHome, // no .env here — the repo root's would inject ANTHROPIC_API_KEY
|
||||
env: env as Record<string, string>,
|
||||
});
|
||||
if (res.status !== 0) throw new Error(`driver failed (${res.status}): ${res.stderr}`);
|
||||
return JSON.parse(res.stdout.trim()) as { ok: boolean; reason?: string };
|
||||
}
|
||||
|
||||
test("creds file present → available, and the Keychain is never consulted", () => {
|
||||
fs.mkdirSync(path.join(fakeHome, ".claude"), { recursive: true });
|
||||
fs.writeFileSync(path.join(fakeHome, ".claude", ".credentials.json"), "{}");
|
||||
// A fake security that would report NOT FOUND — if the probe ran anyway,
|
||||
// ok would still be true, so the no-spawn pin is the argv log staying empty.
|
||||
writeFakeSecurity(1);
|
||||
const check = runAvailable();
|
||||
expect(check.ok).toBe(true);
|
||||
expect(fs.existsSync(securityLog)).toBe(false);
|
||||
});
|
||||
|
||||
test("ANTHROPIC_API_KEY set → available without creds file or Keychain probe", () => {
|
||||
writeFakeSecurity(1);
|
||||
const check = runAvailable({ anthropicKey: "sk-ant-test-not-a-real-key" });
|
||||
expect(check.ok).toBe(true);
|
||||
expect(fs.existsSync(securityLog)).toBe(false);
|
||||
});
|
||||
|
||||
test("darwin: Keychain hit (security exit 0) → available, probed with the exact service name", () => {
|
||||
if (process.platform !== "darwin") return; // keychain branch is darwin-gated in the adapter
|
||||
writeFakeSecurity(0);
|
||||
const check = runAvailable();
|
||||
expect(check.ok).toBe(true);
|
||||
// Exactly one metadata-only probe, against the service subscription installs use.
|
||||
const argv = fs.readFileSync(securityLog, "utf-8").trim().split("\n");
|
||||
expect(argv).toEqual(["find-generic-password -s Claude Code-credentials"]);
|
||||
expect(argv[0]).not.toContain("-w"); // never reads the secret itself
|
||||
});
|
||||
|
||||
test("darwin: Keychain miss (security exit 1) → not available with the no-auth reason", () => {
|
||||
if (process.platform !== "darwin") return;
|
||||
writeFakeSecurity(1);
|
||||
const check = runAvailable();
|
||||
expect(check.ok).toBe(false);
|
||||
expect(check.reason).toContain("No Claude auth found");
|
||||
// The probe DID run (this is the miss path, not a skipped probe).
|
||||
expect(fs.readFileSync(securityLog, "utf-8")).toContain("find-generic-password");
|
||||
});
|
||||
|
||||
test("non-darwin: no creds file + no env key → not available (no Keychain to consult)", () => {
|
||||
if (process.platform === "darwin") return; // darwin covered by the shimmed miss case above
|
||||
const check = runAvailable();
|
||||
expect(check.ok).toBe(false);
|
||||
expect(check.reason).toContain("No Claude auth found");
|
||||
});
|
||||
|
||||
test("claude CLI unresolvable → not-found reason, before any auth sniff", () => {
|
||||
writeFakeSecurity(0); // even a keychain HIT can't rescue a missing binary
|
||||
const check = runAvailable({ bareShimPath: true });
|
||||
expect(check.ok).toBe(false);
|
||||
expect(check.reason).toContain("claude CLI not found on PATH");
|
||||
expect(fs.existsSync(securityLog)).toBe(false); // returned before the auth sniff
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -152,6 +152,40 @@ describe("upstream fix a: root-as-container + walkMd exclusions", () => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("totalMd: a container skill excludes nested child skills' bytes; the grand total sums per-skill with no overlap", () => {
|
||||
// Regression pin for the v1.63 double-count: totalMd on a skill dir that
|
||||
// CONTAINS other skill dirs (the gstack root wraps the whole tree) used to
|
||||
// swallow the children's .md bytes too, so the TOTAL line billed every
|
||||
// nested skill twice. A revert of the topSeg/SKILL.md skip in totalMd
|
||||
// (lib/context-bill.ts) must fail here.
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-nested-total-"));
|
||||
fs.writeFileSync(path.join(tmp, "SKILL.md"), "---\nname: parent\ndescription: p\n---\n# Parent\n");
|
||||
fs.writeFileSync(path.join(tmp, "NOTES.md"), "n".repeat(1_000));
|
||||
// A non-skill subdir (no SKILL.md) still belongs to the parent's total.
|
||||
fs.mkdirSync(path.join(tmp, "references"));
|
||||
fs.writeFileSync(path.join(tmp, "references", "GUIDE.md"), "g".repeat(2_000));
|
||||
// Nested child skill with a LARGE .md — the bytes a revert double-counts.
|
||||
fs.mkdirSync(path.join(tmp, "child"));
|
||||
fs.writeFileSync(path.join(tmp, "child", "SKILL.md"), "---\nname: child\ndescription: c\n---\n# Child\n");
|
||||
fs.writeFileSync(path.join(tmp, "child", "BIG.md"), "x".repeat(50_000));
|
||||
|
||||
const bill = buildBill(tmp);
|
||||
const parent = bill.skills.find((s) => s.name !== "child")!;
|
||||
const child = bill.skills.find((s) => s.name === "child")!;
|
||||
expect(bill.skills).toHaveLength(2);
|
||||
|
||||
const parentOwn =
|
||||
fileBytes(tmp, "SKILL.md") + fileBytes(tmp, "NOTES.md") + fileBytes(tmp, "references", "GUIDE.md");
|
||||
const childOwn = fileBytes(tmp, "child", "SKILL.md") + fileBytes(tmp, "child", "BIG.md");
|
||||
// Parent's total is its OWN files only — the child's 50KB is excluded.
|
||||
expect(parent.totalMdBytes).toBe(parentOwn);
|
||||
expect(child.totalMdBytes).toBe(childOwn);
|
||||
// Grand total = sum of per-skill figures, every byte billed exactly once.
|
||||
expect(bill.totals.totalMdBytes).toBe(parentOwn + childOwn);
|
||||
expect(bill.totals.totalMdBytes).toBe(parent.totalMdBytes + child.totalMdBytes);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("walkMd skips node_modules and dot-directories", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-walk-"));
|
||||
fs.writeFileSync(path.join(tmp, "real.md"), "x");
|
||||
|
||||
@@ -67,6 +67,13 @@ const MODULE_SINKS = [
|
||||
'bin/gstack-gbrain-sync.ts',
|
||||
'bin/gstack-memory-ingest.ts',
|
||||
'browse/src/server.ts',
|
||||
// Code-intelligence adapters (fork port wave 2): the gbrain adapter shells
|
||||
// repo content to the user's gbrain DB and the Sourcebot adapter POSTs
|
||||
// queries to a self-hosted HTTP endpoint — both sensitive-class
|
||||
// (repo-content) sinks, fail-closed. Registered so a refactor that drops
|
||||
// their writeReceipt calls fails CI, not just the tree-sweep scanner.
|
||||
'lib/code-intelligence/gbrain-adapter.ts',
|
||||
'lib/code-intelligence/sourcebot-adapter.ts',
|
||||
// Unconditional: context-bill ships in the same tree as this tripwire. A
|
||||
// missing file must fail loudly (a rename/move that drops its receipt wiring
|
||||
// is exactly what this pins), not silently soften the assertion.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/** OV11: the host-neutral eval-model resolver's contract. */
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { resolveEvalModel } from "../lib/eval-model";
|
||||
|
||||
describe("resolveEvalModel", () => {
|
||||
test("explicit argument wins over everything", () => {
|
||||
expect(resolveEvalModel("capture", "my-model", { GSTACK_EVAL_MODEL: "x" } as never)).toBe("my-model");
|
||||
});
|
||||
test("per-kind env beats the global env", () => {
|
||||
expect(resolveEvalModel("warmup", null, { GSTACK_EVAL_MODEL_WARMUP: "w", GSTACK_EVAL_MODEL: "g" } as never)).toBe("w");
|
||||
});
|
||||
test("global env beats the default", () => {
|
||||
expect(resolveEvalModel("distill", null, { GSTACK_EVAL_MODEL: "g" } as never)).toBe("g");
|
||||
});
|
||||
test("defaults per kind", () => {
|
||||
// capture defaults to Sonnet per D1a (2026-08 review): Opus is opt-in via
|
||||
// explicit arg or GSTACK_EVAL_MODEL_CAPTURE.
|
||||
expect(resolveEvalModel("capture", null, {} as never)).toBe("claude-sonnet-4-6");
|
||||
expect(resolveEvalModel("warmup", null, {} as never)).toBe("claude-haiku-4-5");
|
||||
expect(resolveEvalModel("distill", null, {} as never)).toBe("claude-haiku-4-5-20251001");
|
||||
});
|
||||
test("unknown kind throws instead of silently defaulting", () => {
|
||||
expect(() => resolveEvalModel("banana" as never, null, {} as never)).toThrow();
|
||||
});
|
||||
});
|
||||
+31
@@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
||||
|
||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||
|
||||
## Claimed Limitations Need Evidence
|
||||
|
||||
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||
|
||||
## Continuous Checkpoint Mode
|
||||
|
||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||
@@ -808,6 +812,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
|
||||
|
||||
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
|
||||
|
||||
## Third-Party Web Actions
|
||||
|
||||
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
|
||||
|
||||
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
|
||||
|
||||
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
|
||||
|
||||
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
|
||||
|
||||
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
|
||||
|
||||
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
|
||||
|
||||
## Step 0: Detect platform and base branch
|
||||
|
||||
First, detect the git hosting platform from the remote URL:
|
||||
@@ -895,6 +913,7 @@ sections. Read a section in full before doing its step; do not work from memory.
|
||||
|
||||
| When | Read this section |
|
||||
|------|-------------------|
|
||||
| the ship target is an Apple platform app (.xcodeproj, .xcworkspace, or an app-product Swift package) — read BEFORE Step 1's branch gate and any preflight; store distribution never routes through the branch/PR ceremony | `sections/apple-release.md` |
|
||||
| running the test suites and (if prompt files changed) the eval suites (Steps 4-6) | `sections/tests.md` |
|
||||
| auditing test coverage of the diff (Step 7) | `sections/test-coverage.md` |
|
||||
| auditing plan completion, verification, and scope drift (Step 8) | `sections/plan-completion.md` |
|
||||
@@ -906,6 +925,18 @@ sections. Read a section in full before doing its step; do not work from memory.
|
||||
|
||||
---
|
||||
|
||||
## Step 0.9: Apple target detection
|
||||
|
||||
Shipping to the App Store is not landing a PR. If the repository contains an
|
||||
`.xcodeproj`, `.xcworkspace`, or a Swift package with an app product AND the
|
||||
user's ask is store distribution (App Store, TestFlight, "release my app"),
|
||||
**STOP and Read `~/.claude/skills/gstack/ship/sections/apple-release.md` FIRST**
|
||||
— before the branch gate and any preflight below. Store distribution proceeds
|
||||
from whatever branch the user is on (a clean tree on the base branch is the
|
||||
solo developer's normal case, not an error) and follows the adapter end to
|
||||
end. The branch gate and repository-landing pipeline below apply ONLY to
|
||||
repository-landing asks, including on Apple repos.
|
||||
|
||||
## Step 1: Pre-flight
|
||||
|
||||
1. Check the current branch. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
|
||||
|
||||
+98
-23
@@ -664,6 +664,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
||||
|
||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||
|
||||
## Claimed Limitations Need Evidence
|
||||
|
||||
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||
|
||||
## Continuous Checkpoint Mode
|
||||
|
||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||
@@ -794,6 +798,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
|
||||
|
||||
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
|
||||
|
||||
## Third-Party Web Actions
|
||||
|
||||
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
|
||||
|
||||
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
|
||||
|
||||
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
|
||||
|
||||
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
|
||||
|
||||
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
|
||||
|
||||
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
|
||||
|
||||
## Step 0: Detect platform and base branch
|
||||
|
||||
First, detect the git hosting platform from the remote URL:
|
||||
@@ -878,6 +896,18 @@ Never skip a verification step because a prior `/ship` run already performed it.
|
||||
|
||||
---
|
||||
|
||||
## Step 0.9: Apple target detection
|
||||
|
||||
Shipping to the App Store is not landing a PR. If the repository contains an
|
||||
`.xcodeproj`, `.xcworkspace`, or a Swift package with an app product AND the
|
||||
user's ask is store distribution (App Store, TestFlight, "release my app"),
|
||||
**STOP and Read `$GSTACK_ROOT/ship/sections/apple-release.md` FIRST**
|
||||
— before the branch gate and any preflight below. Store distribution proceeds
|
||||
from whatever branch the user is on (a clean tree on the base branch is the
|
||||
solo developer's normal case, not an error) and follows the adapter end to
|
||||
end. The branch gate and repository-landing pipeline below apply ONLY to
|
||||
repository-landing asks, including on Apple repos.
|
||||
|
||||
## Step 1: Pre-flight
|
||||
|
||||
1. Check the current branch. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
|
||||
@@ -999,41 +1029,70 @@ git fetch origin <base> && git merge origin/<base> --no-edit
|
||||
|
||||
## Test Framework Bootstrap
|
||||
|
||||
**Detect existing test framework and project runtime:**
|
||||
**Read the project's AGENTS.md (and TESTING.md if present) FIRST.** If it documents a test command, the project already told you: no detection, no bootstrap. Skip the rest of bootstrap and use that command in Step 5.
|
||||
|
||||
**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker tells you which ecosystem you're in and which command to OFFER. It does not tell you the command works. Do not execute a candidate test command to "check" it: a probe on a project that never had that runner fails loudly and teaches you nothing, and installing a second framework over a working one is worse.
|
||||
|
||||
```bash
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
# Detect project runtime
|
||||
[ -f Gemfile ] && echo "RUNTIME:ruby"
|
||||
# Definitive ecosystem markers (presence = ecosystem, NOT a command to run)
|
||||
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py"
|
||||
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
|
||||
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
|
||||
[ -f package.json ] && echo "RUNTIME:node"
|
||||
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
|
||||
[ -f go.mod ] && echo "RUNTIME:go"
|
||||
[ -f Cargo.toml ] && echo "RUNTIME:rust"
|
||||
[ -f composer.json ] && echo "RUNTIME:php"
|
||||
[ -f mix.exs ] && echo "RUNTIME:elixir"
|
||||
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
|
||||
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
|
||||
# Detect sub-frameworks
|
||||
[ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails"
|
||||
[ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs"
|
||||
# Check for existing test infrastructure
|
||||
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null
|
||||
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
|
||||
# Existing test path — config files, declared scripts, AND test FILES.
|
||||
# A project with real tests and no config file is the common miss.
|
||||
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null
|
||||
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
|
||||
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
|
||||
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest"
|
||||
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
|
||||
# Rust keeps unit tests inside src/, so file names alone miss them
|
||||
[ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source"
|
||||
# Check opt-out marker
|
||||
[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED"
|
||||
```
|
||||
|
||||
**If test framework detected** (config files or test directories found):
|
||||
Print "Test framework detected: {name} ({N} existing tests). Skipping bootstrap."
|
||||
Map the markers to the command you will OFFER — never to one you run on a guess:
|
||||
|
||||
| Marker | Ecosystem | Candidate command to offer |
|
||||
|--------|-----------|----------------------------|
|
||||
| `manage.py` | Django | `python manage.py test` (or `pytest` when pytest-django is in the deps) |
|
||||
| `pytest.ini` / `tox.ini` / pytest in `pyproject.toml` / `test_*.py` | Python | `pytest` |
|
||||
| `go.mod` (+ any `*_test.go`) | Go | `go test ./...` |
|
||||
| `Cargo.toml` | Rust | `cargo test` |
|
||||
| `pom.xml` | JVM (Maven) | `mvn test` |
|
||||
| `build.gradle` / `build.gradle.kts` | JVM (Gradle) | `./gradlew test` |
|
||||
| `Gemfile` / `Rakefile` / `.rspec` | Ruby | `bundle exec rspec`, `bin/rails test`, or `rake test` |
|
||||
| `mix.exs` | Elixir | `mix test` |
|
||||
| `composer.json` | PHP | `composer test` or `./vendor/bin/phpunit` |
|
||||
| `package.json` with a `test` script | Node | that script, run with the package manager the lockfile names |
|
||||
| `Makefile` with a `test:` target | any | `make test` |
|
||||
|
||||
**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — AGENTS.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to AGENTS.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one.
|
||||
Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns).
|
||||
Store conventions as prose context for use in Phase 8e.5 or Step 7. **Skip the rest of bootstrap.**
|
||||
|
||||
Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `<app>/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. A green `python manage.py test` with no `pytest.ini` is a tested project, not a bootstrap candidate.
|
||||
|
||||
**If BOOTSTRAP_DECLINED** appears: Print "Test bootstrap previously declined — skipping." **Skip the rest of bootstrap.**
|
||||
|
||||
**If NO runtime detected** (no config files found): Use AskUserQuestion:
|
||||
**If NO ecosystem marker matched:** Use AskUserQuestion:
|
||||
"I couldn't detect your project's language. What runtime are you using?"
|
||||
Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests.
|
||||
If the runtime you need isn't listed, offer "Other" and take the runtime plus the test command as free text.
|
||||
If user picks H → write `.gstack/no-test-bootstrap` and continue without tests.
|
||||
|
||||
**If runtime detected but no test framework — bootstrap:**
|
||||
**If an ecosystem matched but there is no existing-test evidence at all — bootstrap:**
|
||||
|
||||
### B2. Research best practices
|
||||
|
||||
@@ -1049,7 +1108,9 @@ If WebSearch is unavailable, use this built-in knowledge table:
|
||||
| Node.js | vitest + @testing-library | jest + @testing-library |
|
||||
| Next.js | vitest + @testing-library/react + playwright | jest + cypress |
|
||||
| Python | pytest + pytest-cov | unittest |
|
||||
| Django | pytest + pytest-django | Django's built-in `manage.py test` (unittest) |
|
||||
| Go | stdlib testing + testify | stdlib only |
|
||||
| JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only |
|
||||
| Rust | cargo test (built-in) + mockall | — |
|
||||
| PHP | phpunit + mockery | pest |
|
||||
| Elixir | ExUnit (built-in) + ex_machina | — |
|
||||
@@ -1378,15 +1439,20 @@ Before analyzing coverage, detect the project's test framework:
|
||||
|
||||
```bash
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
# Detect project runtime
|
||||
[ -f Gemfile ] && echo "RUNTIME:ruby"
|
||||
# Detect project runtime (markers are evidence, not commands to run blind)
|
||||
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django"
|
||||
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
|
||||
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
|
||||
[ -f package.json ] && echo "RUNTIME:node"
|
||||
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
|
||||
[ -f go.mod ] && echo "RUNTIME:go"
|
||||
[ -f Cargo.toml ] && echo "RUNTIME:rust"
|
||||
# Check for existing test infrastructure
|
||||
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini phpunit.xml 2>/dev/null
|
||||
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
|
||||
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
|
||||
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
|
||||
# Check for existing test infrastructure — config files, scripts, AND test files
|
||||
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml 2>/dev/null
|
||||
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
|
||||
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
|
||||
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
|
||||
```
|
||||
|
||||
3. **If no framework detected:** falls through to the Test Framework Bootstrap step (Step 4) which handles full setup.
|
||||
@@ -1818,16 +1884,23 @@ Using the plan file already discovered in Step 8, look for a verification sectio
|
||||
|
||||
### 2. Check for running dev server
|
||||
|
||||
Before invoking browse-based verification, check if a dev server is reachable:
|
||||
Before invoking browse-based verification, find the dev-server URL the way the
|
||||
project declares it — never trust a hardcoded port list alone:
|
||||
|
||||
1. **AGENTS.md first:** look for a documented dev URL or dev command (a
|
||||
`## Development`/`## Testing` section naming a port or URL). Use it.
|
||||
2. **The plan file:** if the plan's verification section names a URL, use it.
|
||||
3. **Fallback probe** (common ports, only when 1-2 found nothing):
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 2>/dev/null || \
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080 2>/dev/null || \
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:5173 2>/dev/null || \
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:4000 2>/dev/null || echo "NO_SERVER"
|
||||
for _p in 3000 8080 5173 4000 4321 8000; do
|
||||
_code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$_p" 2>/dev/null)
|
||||
[ -n "$_code" ] && [ "$_code" != "000" ] && { echo "DEV_SERVER: http://localhost:$_p ($_code)"; break; }
|
||||
done
|
||||
[ -z "${_code:-}" ] || [ "${_code:-000}" = "000" ] && echo "NO_SERVER"
|
||||
```
|
||||
|
||||
**If NO_SERVER:** Skip with "No dev server detected — skipping plan verification. Run /qa separately after deploying."
|
||||
**If NO_SERVER:** Skip with "No dev server detected (checked AGENTS.md, the plan, and common ports) — skipping plan verification. Run /qa separately after deploying, or document the dev URL in AGENTS.md so this step finds it next time."
|
||||
|
||||
### 3. Invoke /qa-only inline
|
||||
|
||||
@@ -2579,6 +2652,8 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS"
|
||||
|
||||
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub) or `glab mr update -d ...` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. **Run the same redaction scan-at-sink (PR body + title) as the create path (Step 19) before editing — scan the temp file, then `gh pr edit --body-file` from it.**
|
||||
|
||||
**REST fallback (#1079):** on some repos `gh pr edit` hard-errors with a GraphQL deprecation mentioning `repository.pullRequest.projectCards` ("Projects (classic) is being deprecated..."). That is a `gh` GraphQL-path problem, not a permissions problem — do not re-ask for auth. Fall back to the REST endpoint, which never touches the deprecated field, using the SAME already-scanned temp file: `PR_NUMBER=$(gh pr view --json number -q .number)` then `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -F body=@"$PR_BODY_FILE"` for the body, and `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -f title="$NEW_TITLE"` when the title edit below hits the same error. Verify with the same self-checks as the primary path.
|
||||
|
||||
**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule.
|
||||
|
||||
1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`).
|
||||
|
||||
+98
-23
@@ -666,6 +666,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
||||
|
||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||
|
||||
## Claimed Limitations Need Evidence
|
||||
|
||||
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||
|
||||
## Continuous Checkpoint Mode
|
||||
|
||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||
@@ -796,6 +800,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
|
||||
|
||||
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
|
||||
|
||||
## Third-Party Web Actions
|
||||
|
||||
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
|
||||
|
||||
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
|
||||
|
||||
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
|
||||
|
||||
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
|
||||
|
||||
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
|
||||
|
||||
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
|
||||
|
||||
## Step 0: Detect platform and base branch
|
||||
|
||||
First, detect the git hosting platform from the remote URL:
|
||||
@@ -880,6 +898,18 @@ Never skip a verification step because a prior `/ship` run already performed it.
|
||||
|
||||
---
|
||||
|
||||
## Step 0.9: Apple target detection
|
||||
|
||||
Shipping to the App Store is not landing a PR. If the repository contains an
|
||||
`.xcodeproj`, `.xcworkspace`, or a Swift package with an app product AND the
|
||||
user's ask is store distribution (App Store, TestFlight, "release my app"),
|
||||
**STOP and Read `$GSTACK_ROOT/ship/sections/apple-release.md` FIRST**
|
||||
— before the branch gate and any preflight below. Store distribution proceeds
|
||||
from whatever branch the user is on (a clean tree on the base branch is the
|
||||
solo developer's normal case, not an error) and follows the adapter end to
|
||||
end. The branch gate and repository-landing pipeline below apply ONLY to
|
||||
repository-landing asks, including on Apple repos.
|
||||
|
||||
## Step 1: Pre-flight
|
||||
|
||||
1. Check the current branch. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
|
||||
@@ -1001,41 +1031,70 @@ git fetch origin <base> && git merge origin/<base> --no-edit
|
||||
|
||||
## Test Framework Bootstrap
|
||||
|
||||
**Detect existing test framework and project runtime:**
|
||||
**Read the project's CLAUDE.md (and TESTING.md if present) FIRST.** If it documents a test command, the project already told you: no detection, no bootstrap. Skip the rest of bootstrap and use that command in Step 5.
|
||||
|
||||
**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker tells you which ecosystem you're in and which command to OFFER. It does not tell you the command works. Do not execute a candidate test command to "check" it: a probe on a project that never had that runner fails loudly and teaches you nothing, and installing a second framework over a working one is worse.
|
||||
|
||||
```bash
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
# Detect project runtime
|
||||
[ -f Gemfile ] && echo "RUNTIME:ruby"
|
||||
# Definitive ecosystem markers (presence = ecosystem, NOT a command to run)
|
||||
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py"
|
||||
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
|
||||
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
|
||||
[ -f package.json ] && echo "RUNTIME:node"
|
||||
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
|
||||
[ -f go.mod ] && echo "RUNTIME:go"
|
||||
[ -f Cargo.toml ] && echo "RUNTIME:rust"
|
||||
[ -f composer.json ] && echo "RUNTIME:php"
|
||||
[ -f mix.exs ] && echo "RUNTIME:elixir"
|
||||
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
|
||||
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
|
||||
# Detect sub-frameworks
|
||||
[ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails"
|
||||
[ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs"
|
||||
# Check for existing test infrastructure
|
||||
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null
|
||||
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
|
||||
# Existing test path — config files, declared scripts, AND test FILES.
|
||||
# A project with real tests and no config file is the common miss.
|
||||
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null
|
||||
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
|
||||
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
|
||||
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest"
|
||||
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
|
||||
# Rust keeps unit tests inside src/, so file names alone miss them
|
||||
[ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source"
|
||||
# Check opt-out marker
|
||||
[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED"
|
||||
```
|
||||
|
||||
**If test framework detected** (config files or test directories found):
|
||||
Print "Test framework detected: {name} ({N} existing tests). Skipping bootstrap."
|
||||
Map the markers to the command you will OFFER — never to one you run on a guess:
|
||||
|
||||
| Marker | Ecosystem | Candidate command to offer |
|
||||
|--------|-----------|----------------------------|
|
||||
| `manage.py` | Django | `python manage.py test` (or `pytest` when pytest-django is in the deps) |
|
||||
| `pytest.ini` / `tox.ini` / pytest in `pyproject.toml` / `test_*.py` | Python | `pytest` |
|
||||
| `go.mod` (+ any `*_test.go`) | Go | `go test ./...` |
|
||||
| `Cargo.toml` | Rust | `cargo test` |
|
||||
| `pom.xml` | JVM (Maven) | `mvn test` |
|
||||
| `build.gradle` / `build.gradle.kts` | JVM (Gradle) | `./gradlew test` |
|
||||
| `Gemfile` / `Rakefile` / `.rspec` | Ruby | `bundle exec rspec`, `bin/rails test`, or `rake test` |
|
||||
| `mix.exs` | Elixir | `mix test` |
|
||||
| `composer.json` | PHP | `composer test` or `./vendor/bin/phpunit` |
|
||||
| `package.json` with a `test` script | Node | that script, run with the package manager the lockfile names |
|
||||
| `Makefile` with a `test:` target | any | `make test` |
|
||||
|
||||
**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — CLAUDE.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to CLAUDE.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one.
|
||||
Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns).
|
||||
Store conventions as prose context for use in Phase 8e.5 or Step 7. **Skip the rest of bootstrap.**
|
||||
|
||||
Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `<app>/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. A green `python manage.py test` with no `pytest.ini` is a tested project, not a bootstrap candidate.
|
||||
|
||||
**If BOOTSTRAP_DECLINED** appears: Print "Test bootstrap previously declined — skipping." **Skip the rest of bootstrap.**
|
||||
|
||||
**If NO runtime detected** (no config files found): Use AskUserQuestion:
|
||||
**If NO ecosystem marker matched:** Use AskUserQuestion:
|
||||
"I couldn't detect your project's language. What runtime are you using?"
|
||||
Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests.
|
||||
If the runtime you need isn't listed, offer "Other" and take the runtime plus the test command as free text.
|
||||
If user picks H → write `.gstack/no-test-bootstrap` and continue without tests.
|
||||
|
||||
**If runtime detected but no test framework — bootstrap:**
|
||||
**If an ecosystem matched but there is no existing-test evidence at all — bootstrap:**
|
||||
|
||||
### B2. Research best practices
|
||||
|
||||
@@ -1051,7 +1110,9 @@ If WebSearch is unavailable, use this built-in knowledge table:
|
||||
| Node.js | vitest + @testing-library | jest + @testing-library |
|
||||
| Next.js | vitest + @testing-library/react + playwright | jest + cypress |
|
||||
| Python | pytest + pytest-cov | unittest |
|
||||
| Django | pytest + pytest-django | Django's built-in `manage.py test` (unittest) |
|
||||
| Go | stdlib testing + testify | stdlib only |
|
||||
| JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only |
|
||||
| Rust | cargo test (built-in) + mockall | — |
|
||||
| PHP | phpunit + mockery | pest |
|
||||
| Elixir | ExUnit (built-in) + ex_machina | — |
|
||||
@@ -1380,15 +1441,20 @@ Before analyzing coverage, detect the project's test framework:
|
||||
|
||||
```bash
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
# Detect project runtime
|
||||
[ -f Gemfile ] && echo "RUNTIME:ruby"
|
||||
# Detect project runtime (markers are evidence, not commands to run blind)
|
||||
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django"
|
||||
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
|
||||
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
|
||||
[ -f package.json ] && echo "RUNTIME:node"
|
||||
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
|
||||
[ -f go.mod ] && echo "RUNTIME:go"
|
||||
[ -f Cargo.toml ] && echo "RUNTIME:rust"
|
||||
# Check for existing test infrastructure
|
||||
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini phpunit.xml 2>/dev/null
|
||||
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
|
||||
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
|
||||
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
|
||||
# Check for existing test infrastructure — config files, scripts, AND test files
|
||||
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml 2>/dev/null
|
||||
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
|
||||
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
|
||||
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
|
||||
```
|
||||
|
||||
3. **If no framework detected:** falls through to the Test Framework Bootstrap step (Step 4) which handles full setup.
|
||||
@@ -1820,16 +1886,23 @@ Using the plan file already discovered in Step 8, look for a verification sectio
|
||||
|
||||
### 2. Check for running dev server
|
||||
|
||||
Before invoking browse-based verification, check if a dev server is reachable:
|
||||
Before invoking browse-based verification, find the dev-server URL the way the
|
||||
project declares it — never trust a hardcoded port list alone:
|
||||
|
||||
1. **CLAUDE.md first:** look for a documented dev URL or dev command (a
|
||||
`## Development`/`## Testing` section naming a port or URL). Use it.
|
||||
2. **The plan file:** if the plan's verification section names a URL, use it.
|
||||
3. **Fallback probe** (common ports, only when 1-2 found nothing):
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 2>/dev/null || \
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080 2>/dev/null || \
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:5173 2>/dev/null || \
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:4000 2>/dev/null || echo "NO_SERVER"
|
||||
for _p in 3000 8080 5173 4000 4321 8000; do
|
||||
_code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$_p" 2>/dev/null)
|
||||
[ -n "$_code" ] && [ "$_code" != "000" ] && { echo "DEV_SERVER: http://localhost:$_p ($_code)"; break; }
|
||||
done
|
||||
[ -z "${_code:-}" ] || [ "${_code:-000}" = "000" ] && echo "NO_SERVER"
|
||||
```
|
||||
|
||||
**If NO_SERVER:** Skip with "No dev server detected — skipping plan verification. Run /qa separately after deploying."
|
||||
**If NO_SERVER:** Skip with "No dev server detected (checked CLAUDE.md, the plan, and common ports) — skipping plan verification. Run /qa separately after deploying, or document the dev URL in CLAUDE.md so this step finds it next time."
|
||||
|
||||
### 3. Invoke /qa-only inline
|
||||
|
||||
@@ -2995,6 +3068,8 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS"
|
||||
|
||||
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub) or `glab mr update -d ...` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. **Run the same redaction scan-at-sink (PR body + title) as the create path (Step 19) before editing — scan the temp file, then `gh pr edit --body-file` from it.**
|
||||
|
||||
**REST fallback (#1079):** on some repos `gh pr edit` hard-errors with a GraphQL deprecation mentioning `repository.pullRequest.projectCards` ("Projects (classic) is being deprecated..."). That is a `gh` GraphQL-path problem, not a permissions problem — do not re-ask for auth. Fall back to the REST endpoint, which never touches the deprecated field, using the SAME already-scanned temp file: `PR_NUMBER=$(gh pr view --json number -q .number)` then `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -F body=@"$PR_BODY_FILE"` for the body, and `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -f title="$NEW_TITLE"` when the title edit below hits the same error. Verify with the same self-checks as the primary path.
|
||||
|
||||
**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule.
|
||||
|
||||
1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`).
|
||||
|
||||
@@ -97,10 +97,12 @@ public final class StateServer {
|
||||
try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)
|
||||
|
||||
// 2. Log the boot token EXACTLY ONCE so the daemon can scrape it.
|
||||
// The daemon will rotate immediately; this log line is dead within
|
||||
// seconds.
|
||||
logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
|
||||
// 2. Announce bootstrap WITHOUT the token. The daemon reads the boot
|
||||
// token from the 0600 file above (copyFileFromAppContainer); the
|
||||
// os_log line that used to carry it had no consumer and handed a
|
||||
// live credential to anything reading the unified log during the
|
||||
// launch window. Port/build stay for diagnostics.
|
||||
logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
|
||||
|
||||
// 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6;
|
||||
// local tooling may use IPv4. Never bind 0.0.0.0 or ::.
|
||||
@@ -149,7 +151,19 @@ public final class StateServer {
|
||||
let params = NWParameters.tcp
|
||||
params.allowLocalEndpointReuse = true
|
||||
|
||||
let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
|
||||
// IPv4 has no CoreDevice tunnel path, so it binds strictly to
|
||||
// loopback at the socket level; IPv6 keeps the wildcard bind and
|
||||
// relies on the per-connection peer check below for tunnel peers.
|
||||
let listener: NWListener
|
||||
switch family {
|
||||
case .ipv4:
|
||||
params.requiredLocalEndpoint = NWEndpoint.hostPort(
|
||||
host: NWEndpoint.Host("127.0.0.1"),
|
||||
port: NWEndpoint.Port(rawValue: port)!)
|
||||
listener = try NWListener(using: params)
|
||||
case .ipv6:
|
||||
listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
|
||||
}
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
if case .ready = state {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* #538: the office-hours founder-resources pitch takes no for an answer.
|
||||
*
|
||||
* The reporter found that memory instructions telling the agent to stop
|
||||
* showing the 34-resource pool kept being overridden on every update. The
|
||||
* fix is a config key — `founder_resources` — that session context cannot
|
||||
* override: `false` skips the entire section silently, forever, until the
|
||||
* user re-enables it.
|
||||
*
|
||||
* Pins: (1) the config key's default, persistence, and validation through
|
||||
* the real bin/gstack-config subprocess; (2) the generated section gates on
|
||||
* the key BEFORE any resource content; (3) the opt-out write is verified
|
||||
* before the skill may promise "never again" (R6 — a failed write must not
|
||||
* produce a false promise).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { execFileSync } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const CONFIG_BIN = path.join(ROOT, "bin", "gstack-config");
|
||||
const SECTION = path.join(ROOT, "office-hours", "sections", "design-and-handoff.md");
|
||||
|
||||
let tmpHome: string;
|
||||
beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-538-")); });
|
||||
afterEach(() => { fs.rmSync(tmpHome, { recursive: true, force: true }); });
|
||||
|
||||
function cfg(args: string[]): string {
|
||||
return execFileSync(CONFIG_BIN, args, {
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
}).trim();
|
||||
}
|
||||
|
||||
describe("founder_resources config key (#538)", () => {
|
||||
test("defaults to true (pitch stays on for everyone who never opted out)", () => {
|
||||
expect(cfg(["get", "founder_resources"])).toBe("true");
|
||||
});
|
||||
|
||||
test("set false persists and reads back — the write-verify contract", () => {
|
||||
cfg(["set", "founder_resources", "false"]);
|
||||
expect(cfg(["get", "founder_resources"])).toBe("false");
|
||||
});
|
||||
|
||||
test("invalid values are rejected to the default, never persisted as-is", () => {
|
||||
execFileSync(CONFIG_BIN, ["set", "founder_resources", "banana"], {
|
||||
encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
});
|
||||
expect(cfg(["get", "founder_resources"])).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("office-hours section gates on the key (#538)", () => {
|
||||
const src = fs.readFileSync(SECTION, "utf-8");
|
||||
|
||||
test("the opt-out check precedes any resource content", () => {
|
||||
const gate = src.indexOf("gstack-config get founder_resources");
|
||||
const pool = src.indexOf("Resource Pool");
|
||||
expect(gate).toBeGreaterThan(-1);
|
||||
expect(pool).toBeGreaterThan(-1);
|
||||
expect(gate).toBeLessThan(pool);
|
||||
});
|
||||
|
||||
test("skip is silent and permanent — never means never", () => {
|
||||
expect(src).toContain("skip this entire section silently");
|
||||
expect(src).toContain("gstack-config set founder_resources true");
|
||||
});
|
||||
|
||||
test("the opt-out write is verified before any promise (R6)", () => {
|
||||
expect(src).toContain("VERIFY the write");
|
||||
expect(src).toMatch(/read back\s*\n?`false`/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* CI secret gate contract (R4/R9, fork port wave 2).
|
||||
*
|
||||
* .github/scripts/gate-secret-scan.mjs pipes a unified diff's ADDED lines
|
||||
* into bin/gstack-redact and enforces: HIGH fails (exit 1), MEDIUM is an
|
||||
* advisory count only (no human in CI to confirm, so it must never fail
|
||||
* the check), clean passes. The workflow-level pathspec excludes keep the
|
||||
* planted-bug fixtures out of the diff entirely; this pins the script's
|
||||
* own exit contract with live subprocess runs.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { spawnSync } from "child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const SCRIPT = join(ROOT, ".github", "scripts", "gate-secret-scan.mjs");
|
||||
|
||||
function scan(diff: string, cwd: string = ROOT): { code: number; out: string } {
|
||||
const res = spawnSync("node", [SCRIPT], {
|
||||
cwd,
|
||||
input: diff,
|
||||
encoding: "utf-8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
return { code: res.status ?? -1, out: `${res.stdout}${res.stderr}` };
|
||||
}
|
||||
|
||||
describe("gate-secret-scan.mjs exit contract", () => {
|
||||
test("clean added lines pass", () => {
|
||||
const r = scan("+const x = 1;\n+++ b/file.ts\n+// harmless\n");
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toContain("0 high");
|
||||
});
|
||||
|
||||
// The planted PEM is assembled at runtime — header split included — so this
|
||||
// FILE never carries a live-format private key: the repo's own prepush
|
||||
// credential guard scans pushed diffs and (correctly) blocks any one-line
|
||||
// BEGIN…END spelling regardless of body. The scanner under test still
|
||||
// receives the true live shape.
|
||||
const PEM_BEGIN = ["-----BEGIN RSA ", "PRIVATE KEY-----"].join("");
|
||||
const PEM_END = ["-----END RSA ", "PRIVATE KEY-----"].join("");
|
||||
const PLANTED_PEM_BODY = ["MIIEow", "IBAAKC", "AQEA"].join("");
|
||||
|
||||
test("a HIGH credential in an added line fails the gate", () => {
|
||||
const r = scan(
|
||||
`+${PEM_BEGIN}\n+${PLANTED_PEM_BODY}\n+${PEM_END}\n`,
|
||||
);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.out).toContain("1 high");
|
||||
});
|
||||
|
||||
test("removed lines and context are ignored — only additions are scanned", () => {
|
||||
const r = scan(
|
||||
`-${PEM_BEGIN}\n-${PLANTED_PEM_BODY}\n${PEM_END}\n+just an addition\n`,
|
||||
);
|
||||
expect(r.code).toBe(0);
|
||||
});
|
||||
|
||||
test("MEDIUM findings are advisory only — never fail CI", () => {
|
||||
// A Stripe publishable-key shape sits at MEDIUM in the taxonomy
|
||||
// (context-variable; a human confirms interactively, CI cannot).
|
||||
const r = scan(`+const key = "pk_live_${"a".repeat(24)}";\n`);
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toMatch(/\d+ advisory/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gate-secret-scan.mjs fail-closed legs", () => {
|
||||
test("oversize diff (report.oversize) fails the gate", () => {
|
||||
// The script pins --max-bytes 16000000; bin/gstack-redact refuses to scan
|
||||
// anything larger and reports oversize:true (fail-closed). The gate must
|
||||
// exit 1 rather than pass unscanned bytes. ~17MB of added lines guarantees
|
||||
// the joined additions exceed the cap.
|
||||
const line = `+${"a".repeat(8190)}\n`;
|
||||
const r = scan(line.repeat(2100));
|
||||
expect(r.code).toBe(1);
|
||||
// Proves the failure came from the parsed report (the engine surfaces
|
||||
// oversize as a fail-closed HIGH), not from a crashed subprocess.
|
||||
expect(r.out).toContain("1 high");
|
||||
}, 60_000);
|
||||
|
||||
test("unexpected gstack-redact exit code fails the gate even when the report is clean", () => {
|
||||
// Stub bin/gstack-redact that emits a CLEAN JSON report but exits 1 —
|
||||
// not one of the contract codes (0 clean / 2 MEDIUM / 3 HIGH). The gate
|
||||
// must treat the unexpected exit as failure: a broken scanner reporting
|
||||
// "all clear" is exactly the fail-open shape this leg guards against.
|
||||
const dir = mkdtempSync(join(tmpdir(), "gate-secret-scan-stub-"));
|
||||
try {
|
||||
mkdirSync(join(dir, "bin"));
|
||||
writeFileSync(
|
||||
join(dir, "bin", "gstack-redact"),
|
||||
[
|
||||
"#!/usr/bin/env bun",
|
||||
'let input = "";',
|
||||
'process.stdin.setEncoding("utf8");',
|
||||
'process.stdin.on("data", (c) => { input += c; });',
|
||||
'process.stdin.on("end", () => {',
|
||||
' console.log(JSON.stringify({ findings: [], counts: { HIGH: 0, MEDIUM: 0, LOW: 0, WARN: 0 }, repoVisibility: "public", oversize: false }));',
|
||||
" process.exit(1);",
|
||||
"});",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const r = scan("+const x = 1;\n", dir);
|
||||
expect(r.out).toContain("0 high"); // the clean report WAS parsed...
|
||||
expect(r.code).toBe(1); // ...and the gate still failed on the exit code
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("missing gstack-redact (spawn crash, empty stdout) exits nonzero — never fail-open", () => {
|
||||
// cwd with no bin/gstack-redact at all: bun exits module-not-found with
|
||||
// empty stdout. Whatever the exact failure shape, the gate must not
|
||||
// report success.
|
||||
const dir = mkdtempSync(join(tmpdir(), "gate-secret-scan-absent-"));
|
||||
try {
|
||||
const r = scan("+const x = 1;\n", dir);
|
||||
expect(r.code).not.toBe(0);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
rmSync,
|
||||
chmodSync,
|
||||
} from "fs";
|
||||
@@ -39,24 +40,6 @@ interface FakeEnv {
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
interface ShellCase {
|
||||
name: "bash" | "zsh";
|
||||
command: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
function shellAvailable(command: string): boolean {
|
||||
const result = spawnSync(command, ["-c", "exit 0"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
const SHELLS: ShellCase[] = [
|
||||
{ name: "bash", command: "bash", available: shellAvailable("bash") },
|
||||
{ name: "zsh", command: "zsh", available: shellAvailable("zsh") },
|
||||
];
|
||||
|
||||
function makeFakeEnv(): FakeEnv {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gbrain-voyage-init-"));
|
||||
const home = join(tmp, "home");
|
||||
@@ -69,13 +52,8 @@ function makeFakeEnv(): FakeEnv {
|
||||
// succeeds on init (writes a sentinel pglite config), and returns canned
|
||||
// output for --version. Nothing else is needed for the shape test.
|
||||
const fake = `#!/bin/sh
|
||||
{
|
||||
echo "__CALL__"
|
||||
for arg in "$@"; do
|
||||
printf 'arg=%s\\n' "$arg"
|
||||
done
|
||||
echo "__END__"
|
||||
} >> "${argvLog}"
|
||||
echo "$@" >> "${argvLog}"
|
||||
echo "$#" >> "${argvLog}.argc"
|
||||
case "$1" in
|
||||
--version)
|
||||
echo "gbrain 0.37.1.0"
|
||||
@@ -104,11 +82,11 @@ exit 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim reimplementation of the skill template's voyage-code-3 conditional.
|
||||
* The template (setup-gbrain/SKILL.md.tmpl Path 3, Step 1.5 inside the
|
||||
* rollback wrapper, Step 4.5 Path 4 Yes branch) instructs the model to execute
|
||||
* this shell; we execute the same shell here and assert the argv passed to
|
||||
* gbrain matches the contract under both bash and zsh.
|
||||
* Verbatim reimplementation of the skill template's voyage-code-3
|
||||
* conditional. The template (setup-gbrain/SKILL.md.tmpl Path 3, Step 1.5
|
||||
* inside the rollback wrapper, Step 4.5 Path 4 Yes branch) instructs the
|
||||
* model to execute this bash; we execute the same bash here and assert the
|
||||
* argv passed to gbrain matches the contract.
|
||||
*
|
||||
* If the template changes the flag set or the env-var name, this test
|
||||
* should fail until the shell here is updated too — by design.
|
||||
@@ -116,15 +94,19 @@ exit 0
|
||||
function runInitWithVoyageGate(
|
||||
env: FakeEnv,
|
||||
voyageKey: string | undefined,
|
||||
shell: ShellCase,
|
||||
): string[][] {
|
||||
shell: "bash" | "zsh" = "bash",
|
||||
): string[] {
|
||||
// The template's #1798 shape: flags ride the positional params, because an
|
||||
// unquoted $VAR does NOT word-split under zsh — the whole flag string
|
||||
// arrived as ONE argv word and gbrain silently fell back to its default
|
||||
// embedding model.
|
||||
const script = `
|
||||
set -u
|
||||
set --
|
||||
if [ -n "\${VOYAGE_API_KEY:-}" ]; then
|
||||
gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
|
||||
else
|
||||
gbrain init --pglite --json
|
||||
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
|
||||
fi
|
||||
gbrain init --pglite --json "$@"
|
||||
`;
|
||||
const baseEnv: Record<string, string> = {
|
||||
...process.env,
|
||||
@@ -136,86 +118,111 @@ fi
|
||||
} else {
|
||||
baseEnv.VOYAGE_API_KEY = voyageKey;
|
||||
}
|
||||
const result = spawnSync(shell.command, ["-c", script], {
|
||||
const result = spawnSync(shell, ["-c", script], {
|
||||
encoding: "utf-8",
|
||||
env: baseEnv,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${shell.name} init script exited ${result.status}: ${result.stderr}`,
|
||||
);
|
||||
throw new Error(`init script exited ${result.status}: ${result.stderr}`);
|
||||
}
|
||||
return parseArgvLog(env.argvLog);
|
||||
return readFileSync(env.argvLog, "utf-8").trim().split("\n");
|
||||
}
|
||||
|
||||
function parseArgvLog(argvLog: string): string[][] {
|
||||
const calls: string[][] = [];
|
||||
let current: string[] | undefined;
|
||||
|
||||
for (const line of readFileSync(argvLog, "utf-8").trim().split("\n")) {
|
||||
if (line === "__CALL__") {
|
||||
current = [];
|
||||
continue;
|
||||
}
|
||||
if (line === "__END__") {
|
||||
if (current) calls.push(current);
|
||||
current = undefined;
|
||||
continue;
|
||||
}
|
||||
if (current && line.startsWith("arg=")) {
|
||||
current.push(line.slice("arg=".length));
|
||||
}
|
||||
}
|
||||
|
||||
return calls;
|
||||
function lastArgc(env: FakeEnv): number {
|
||||
const lines = readFileSync(`${env.argvLog}.argc`, "utf-8").trim().split("\n");
|
||||
return parseInt(lines[lines.length - 1], 10);
|
||||
}
|
||||
|
||||
const HAVE_ZSH = spawnSync("zsh", ["-c", "true"]).status === 0;
|
||||
|
||||
describe("voyage-code-3 default for gstack-driven PGLite init", () => {
|
||||
for (const shell of SHELLS) {
|
||||
const describeShell = shell.available ? describe : describe.skip;
|
||||
it("passes voyage-code-3 flags when VOYAGE_API_KEY is set", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, "vk_test_set");
|
||||
expect(calls.length).toBe(1);
|
||||
const argv = calls[0];
|
||||
expect(argv).toContain("init --pglite --json");
|
||||
expect(argv).toContain("--embedding-model voyage:voyage-code-3");
|
||||
expect(argv).toContain("--embedding-dimensions 1024");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
describeShell(`under ${shell.name}`, () => {
|
||||
it("passes voyage-code-3 flags as four separate argv entries when VOYAGE_API_KEY is set", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, "vk_test_set", shell);
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
"init",
|
||||
"--pglite",
|
||||
"--json",
|
||||
"--embedding-model",
|
||||
"voyage:voyage-code-3",
|
||||
"--embedding-dimensions",
|
||||
"1024",
|
||||
],
|
||||
]);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
it("omits voyage flags when VOYAGE_API_KEY is unset", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, undefined);
|
||||
expect(calls.length).toBe(1);
|
||||
const argv = calls[0];
|
||||
expect(argv).toContain("init --pglite --json");
|
||||
expect(argv).not.toContain("voyage");
|
||||
expect(argv).not.toContain("--embedding-model");
|
||||
expect(argv).not.toContain("--embedding-dimensions");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("omits voyage flags when VOYAGE_API_KEY is unset", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, undefined, shell);
|
||||
expect(calls).toEqual([["init", "--pglite", "--json"]]);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
it("zsh: flags arrive as SEPARATE argv words (#1798 — the shell that broke)", () => {
|
||||
if (!HAVE_ZSH) return; // zsh ships on macOS; skip quietly elsewhere
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, "vk_test_set", "zsh");
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]).toContain("--embedding-model voyage:voyage-code-3");
|
||||
// init --pglite --json + 4 flag words = 7 argv entries. The pre-#1798
|
||||
// unquoted-var shape produced 4 under zsh (the whole flag string as one
|
||||
// word), and gbrain silently fell back to its default embedding model.
|
||||
expect(lastArgc(env)).toBe(7);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats empty-string VOYAGE_API_KEY the same as unset (no false positive)", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, "", shell);
|
||||
expect(calls).toEqual([["init", "--pglite", "--json"]]);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
it("demonstrates the #1798 collision: an unquoted flags var is ONE word under zsh", () => {
|
||||
if (!HAVE_ZSH) return;
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const brokenShape = `
|
||||
set -u
|
||||
GBRAIN_EMBED_FLAGS="--embedding-model voyage:voyage-code-3 --embedding-dimensions 1024"
|
||||
gbrain init --pglite --json $GBRAIN_EMBED_FLAGS
|
||||
`;
|
||||
const result = spawnSync("zsh", ["-c", brokenShape], {
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, HOME: env.home, PATH: `${env.bindir}:/usr/bin:/bin` },
|
||||
});
|
||||
});
|
||||
}
|
||||
expect(result.status).toBe(0);
|
||||
expect(lastArgc(env)).toBe(4); // init, --pglite, --json, "<entire flag string>"
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("template uses the positional-params shape, not an unquoted flags var", () => {
|
||||
const tmpl = readFileSync(
|
||||
join(import.meta.dir, "..", "setup-gbrain", "SKILL.md.tmpl"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(tmpl).not.toContain("$GBRAIN_EMBED_FLAGS");
|
||||
const sites = tmpl.match(/gbrain init --pglite --json "\$@"/g) || [];
|
||||
expect(sites.length).toBe(3);
|
||||
const setSites = tmpl.match(/set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024/g) || [];
|
||||
expect(setSites.length).toBe(3);
|
||||
});
|
||||
|
||||
it("treats empty-string VOYAGE_API_KEY the same as unset (no false positive)", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, "");
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]).not.toContain("voyage");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("template alignment: the .tmpl actually contains the voyage gate", () => {
|
||||
@@ -225,13 +232,11 @@ describe("template alignment: the .tmpl actually contains the voyage gate", () =
|
||||
const TEMPLATE_PATH = join(import.meta.dir, "..", "setup-gbrain", "SKILL.md.tmpl");
|
||||
const tmpl = readFileSync(TEMPLATE_PATH, "utf-8");
|
||||
|
||||
it("setup-gbrain template gates the embedding-model flag on VOYAGE_API_KEY without word-splitting", () => {
|
||||
it("setup-gbrain template gates the embedding-model flag on VOYAGE_API_KEY", () => {
|
||||
// Should appear at least once (currently 3 init sites use the same gate).
|
||||
expect(tmpl).toContain('if [ -n "${VOYAGE_API_KEY:-}" ]; then');
|
||||
expect(tmpl).toContain(
|
||||
"gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024",
|
||||
);
|
||||
expect(tmpl).not.toContain("GBRAIN_EMBED_FLAGS");
|
||||
expect(tmpl).toContain("--embedding-model voyage:voyage-code-3");
|
||||
expect(tmpl).toContain("--embedding-dimensions 1024");
|
||||
});
|
||||
|
||||
it("setup-gbrain template uses the conditional gate at all 3 PGLite init sites", () => {
|
||||
@@ -239,10 +244,5 @@ describe("template alignment: the .tmpl actually contains the voyage gate", () =
|
||||
// init site, update this expectation deliberately.
|
||||
const matches = tmpl.match(/if \[ -n "\$\{VOYAGE_API_KEY:-\}" \]; then/g);
|
||||
expect(matches?.length).toBe(3);
|
||||
|
||||
const voyageInitMatches = tmpl.match(
|
||||
/gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024/g,
|
||||
);
|
||||
expect(voyageInitMatches?.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,3 +269,105 @@ describe('get without arg (auto-detect from current dir)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── #2140 sync-path chokepoint ──────────────────────────────────────────────
|
||||
// The tier above is a STORE. This block pins the ENFORCEMENT: a direct
|
||||
// gstack-gbrain-sync invocation (skill prose bypassed — cron, curiosity,
|
||||
// automation) must honor deny/read-only at the code-import stage, and the
|
||||
// egress receipt's "per-repo policy chokepoint (repoPolicyTier)" consent
|
||||
// string must describe code that exists. Wave-1 shipped the receipt string
|
||||
// without the function; these tests make that impossible to repeat.
|
||||
|
||||
describe('gstack-gbrain-sync code stage honors the repo policy (#2140 sync path)', () => {
|
||||
const SYNC = path.join(ROOT, 'bin', 'gstack-gbrain-sync.ts');
|
||||
const REPO_URL = 'https://github.com/acme/widget.git';
|
||||
|
||||
let repoDir: string;
|
||||
|
||||
function makeRepo(): void {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-policy-repo-'));
|
||||
const git = (...args: string[]) =>
|
||||
spawnSync('git', args, { cwd: repoDir, encoding: 'utf-8' });
|
||||
git('init', '-q', '.');
|
||||
git('remote', 'add', 'origin', REPO_URL);
|
||||
fs.writeFileSync(path.join(repoDir, 'README.md'), 'fixture\n');
|
||||
git('add', '-A');
|
||||
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture');
|
||||
}
|
||||
|
||||
function runSync(): { status: number; text: string; stages: any[] } {
|
||||
const res = spawnSync('bun', [SYNC, '--code-only', '--incremental'], {
|
||||
cwd: repoDir,
|
||||
encoding: 'utf-8',
|
||||
timeout: 60_000,
|
||||
// HOME also redirected so engine detection can't find a real ~/.gbrain.
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome, HOME: tmpHome },
|
||||
});
|
||||
let stages: any[] = [];
|
||||
try {
|
||||
stages = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, '.gbrain-sync-state.json'), 'utf-8'),
|
||||
).last_stages || [];
|
||||
} catch {
|
||||
// state file may be absent on early refusal paths — text asserts cover it
|
||||
}
|
||||
return {
|
||||
status: res.status ?? -1,
|
||||
text: `${res.stdout || ''}\n${res.stderr || ''}`,
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (repoDir) fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('deny → code stage refuses loudly, exit 1, status refused-policy-deny', () => {
|
||||
makeRepo();
|
||||
expect(run(['set', REPO_URL, 'deny']).status).toBe(0);
|
||||
const r = runSync();
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.text).toContain('refused');
|
||||
expect(r.text).toContain('deny');
|
||||
const code = r.stages.find((s: any) => s.name === 'code');
|
||||
expect(code?.detail?.status).toBe('refused-policy-deny');
|
||||
});
|
||||
|
||||
test('read-only → clean skip (exit 0), status skipped-policy-read-only', () => {
|
||||
makeRepo();
|
||||
expect(run(['set', REPO_URL, 'read-only']).status).toBe(0);
|
||||
const r = runSync();
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.text).toContain('read-only');
|
||||
const code = r.stages.find((s: any) => s.name === 'code');
|
||||
expect(code?.detail?.status).toBe('skipped-policy-read-only');
|
||||
});
|
||||
|
||||
test('store exists but unreadable → fail-closed refusal, never bypassed', () => {
|
||||
if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod semantics differ
|
||||
makeRepo();
|
||||
expect(run(['set', REPO_URL, 'deny']).status).toBe(0);
|
||||
fs.chmodSync(policyFile(), 0o000);
|
||||
try {
|
||||
const r = runSync();
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.text).toContain('refus');
|
||||
const code = r.stages.find((s: any) => s.name === 'code');
|
||||
expect(code?.detail?.status).toBe('refused-policy-unreadable');
|
||||
} finally {
|
||||
fs.chmodSync(policyFile(), 0o600);
|
||||
}
|
||||
});
|
||||
|
||||
test('no policy store → fail-open, stage proceeds past the gate (no policy status)', () => {
|
||||
makeRepo();
|
||||
const r = runSync();
|
||||
const code = r.stages.find((s: any) => s.name === 'code');
|
||||
// With no engine in the redirected HOME the stage skips for ENGINE
|
||||
// reasons — what matters is that no policy refusal fired and the exit
|
||||
// is clean, preserving pre-policy behavior for every non-policy user.
|
||||
expect(r.status).toBe(0);
|
||||
expect(String(code?.detail?.status || '')).not.toContain('policy');
|
||||
expect(r.text).not.toContain('refused');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1079,8 +1079,11 @@ describe('PLAN_VERIFICATION_EXEC placeholder', () => {
|
||||
expect(shipSkill).toContain('qa-only');
|
||||
});
|
||||
|
||||
test('contains localhost reachability check', () => {
|
||||
expect(shipSkill).toContain('localhost:3000');
|
||||
test('contains dev-server discovery (CLAUDE.md first, then a port probe)', () => {
|
||||
// Fork port wave 2: the hardcoded 4-port list became read-CLAUDE.md-or-
|
||||
// probe; the probe loops common ports instead of naming each once.
|
||||
expect(shipSkill).toContain('CLAUDE.md first');
|
||||
expect(shipSkill).toContain('http://localhost:$_p');
|
||||
expect(shipSkill).toContain('NO_SERVER');
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { chmodSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
||||
import { chmodSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { delimiter, join } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
@@ -52,10 +52,47 @@ fi
|
||||
chmodSync(fakeBin, 0o755);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like writeFakeGbrain, but every invocation appends its argv to `logFile`.
|
||||
* Still answers `--version` successfully ON PURPOSE: a revert from the
|
||||
* memoized PATH stat scan back to the old `gbrain --version` spawn probe
|
||||
* would pass every non-logging test — only the argv log catches it.
|
||||
*/
|
||||
function writeLoggingGbrain(binDir: string, logFile: string): void {
|
||||
if (process.platform === "win32") {
|
||||
writeFileSync(
|
||||
join(binDir, "gbrain.cmd"),
|
||||
`@echo off\r\necho %* >> "${logFile}"\r\nif "%1"=="--version" (\r\n echo gbrain 0.test\r\n) else (\r\n echo fake gbrain %*\r\n)\r\n`,
|
||||
"utf-8",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fakeBin = join(binDir, "gbrain");
|
||||
writeFileSync(
|
||||
fakeBin,
|
||||
`#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "${logFile}"
|
||||
if [ "$1" = "--version" ]; then
|
||||
echo "gbrain 0.test"
|
||||
else
|
||||
echo "fake gbrain $*"
|
||||
fi
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
chmodSync(fakeBin, 0o755);
|
||||
}
|
||||
|
||||
function prependPath(binDir: string): Record<string, string> {
|
||||
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "PATH";
|
||||
const currentPath = process.env[pathKey] || "";
|
||||
return { [pathKey]: `${binDir}${delimiter}${currentPath}` };
|
||||
return {
|
||||
[pathKey]: `${binDir}${delimiter}${currentPath}`,
|
||||
// Cold process spawns on a loaded machine can exceed the 500ms default
|
||||
// budget; the fake gbrain is instant once spawned, so give it headroom.
|
||||
GSTACK_BRAIN_TIMEOUT_MS: "10000",
|
||||
};
|
||||
}
|
||||
|
||||
describe("gstack-brain-context-load CLI", () => {
|
||||
@@ -252,6 +289,111 @@ describe("gstack-brain-context-load — graceful gbrain absence", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("manifest filter: blocks reach gbrain as --filter args with template vars resolved (#1687)", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
|
||||
const binDir = join(dir, "bin");
|
||||
mkdirSync(binDir);
|
||||
writeFakeGbrain(binDir);
|
||||
const skillFile = join(dir, "SKILL.md");
|
||||
writeFileSync(
|
||||
skillFile,
|
||||
`---
|
||||
name: x
|
||||
gbrain:
|
||||
schema: 1
|
||||
context_queries:
|
||||
- id: prior-sessions
|
||||
kind: list
|
||||
filter:
|
||||
type: ceo-plan
|
||||
tags_contains: "repo:{repo_slug}"
|
||||
sort: updated_at_desc
|
||||
limit: 5
|
||||
render_as: "## Prior sessions"
|
||||
---
|
||||
`,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
try {
|
||||
const r = runScript(["--skill-file", skillFile, "--repo", "my-test-repo"], prependPath(binDir));
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("fake gbrain list_pages");
|
||||
expect(r.stdout).toContain("--filter type=ceo-plan");
|
||||
expect(r.stdout).toContain("--filter tags_contains=repo:my-test-repo");
|
||||
expect(r.stdout).toContain("--sort updated_at_desc");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("gbrain detection never spawns gbrain — stat-based PATH scan, not a `--version` probe (revert trap)", () => {
|
||||
// The fix replaced a per-query `gbrain --version` spawn probe with a
|
||||
// memoized PATH stat scan. The plain writeFakeGbrain shim still ANSWERS
|
||||
// --version, so a revert to the spawn probe passes every other test in
|
||||
// this file. This fake logs its argv: detection must invoke gbrain zero
|
||||
// times, so the only invocations are the 3 default-manifest list_pages
|
||||
// queries — a revert adds `--version` lines (and re-probing adds one per
|
||||
// query) and fails exactly here.
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
|
||||
const binDir = join(dir, "bin");
|
||||
mkdirSync(binDir);
|
||||
const logFile = join(dir, "gbrain-argv.log");
|
||||
writeLoggingGbrain(binDir, logFile);
|
||||
|
||||
try {
|
||||
const r = runScript(["--repo", "test-repo", "--explain", "--quiet"], prependPath(binDir));
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stderr).toContain("queries=3");
|
||||
const invocations = readFileSync(logFile, "utf-8").split("\n").filter(Boolean);
|
||||
expect(invocations.some((argv) => argv.includes("--version"))).toBe(false);
|
||||
// Exactly the 3 real queries — no extra availability spawns of any shape.
|
||||
expect(invocations).toHaveLength(3);
|
||||
for (const argv of invocations) expect(argv.startsWith("list_pages")).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("availability survives a 1ms query budget — detection is not subject to GSTACK_BRAIN_TIMEOUT_MS", () => {
|
||||
// The spawn probe ran under the same MCP_TIMEOUT_MS budget as the queries,
|
||||
// so a cold spawn slower than the budget misreported gbrain as MISSING.
|
||||
// With the stat scan, a 1ms budget kills the queries themselves (SKIP)
|
||||
// but detection still sees the CLI — "gbrain CLI missing" must not appear.
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
|
||||
const binDir = join(dir, "bin");
|
||||
mkdirSync(binDir);
|
||||
// A SLOW fake, not the shared instant one: on a fast CI runner the
|
||||
// instant fake answered inside even a 1ms budget (observed dur=0ms on
|
||||
// ubicloud) and no SKIP ever printed. Sleeping makes the timeout
|
||||
// deterministic on every machine; --version stays instant so detection
|
||||
// has nothing to wait on.
|
||||
const fakeBin = join(binDir, "gbrain");
|
||||
writeFileSync(
|
||||
fakeBin,
|
||||
`#!/bin/sh
|
||||
if [ "$1" = "--version" ]; then
|
||||
echo "gbrain 0.test"
|
||||
else
|
||||
sleep 0.3
|
||||
echo "fake gbrain $*"
|
||||
fi
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
chmodSync(fakeBin, 0o755);
|
||||
|
||||
try {
|
||||
const env = { ...prependPath(binDir), GSTACK_BRAIN_TIMEOUT_MS: "1" };
|
||||
const r = runScript(["--repo", "test-repo", "--explain", "--quiet"], env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stderr).toContain("SKIP");
|
||||
expect(r.stderr).not.toContain("gbrain CLI missing");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("vector + list queries still complete (with SKIP) when gbrain CLI is missing", () => {
|
||||
// We can't easily un-install gbrain; rely on the helper's own missing-binary
|
||||
// detection. The default manifest uses kind: list which calls gbrain. If
|
||||
|
||||
@@ -63,6 +63,39 @@ describe("gstack-decision-log", () => {
|
||||
const r = log("not json", true);
|
||||
expect(r.code).toBe(1);
|
||||
});
|
||||
test("--supersede with a replacement body records the replacement, linked to the old id", () => {
|
||||
const id = log('{"decision":"old-call","scope":"repo","source":"user"}').out;
|
||||
const out = logFlag(
|
||||
`--supersede ${id} '{"decision":"new-call","rationale":"better","scope":"repo","source":"user"}'`,
|
||||
);
|
||||
expect(out).toContain(id);
|
||||
expect(search()).toContain("new-call"); // the replacement is NOT silently dropped
|
||||
expect(search()).not.toContain("old-call");
|
||||
const arr = JSON.parse(search("--json"));
|
||||
expect(arr.find((d: any) => d.decision === "new-call")?.supersedes).toBe(id);
|
||||
});
|
||||
test("--supersede with an INVALID replacement persists nothing (old stays active)", () => {
|
||||
const id = log('{"decision":"keep-me","scope":"repo","source":"user"}').out;
|
||||
let code = 0;
|
||||
try {
|
||||
logFlag(`--supersede ${id} '{"decision":""}'`);
|
||||
} catch (e: any) {
|
||||
code = e.status || 1;
|
||||
}
|
||||
expect(code).toBe(1);
|
||||
expect(search()).toContain("keep-me"); // not retired by a failed replacement
|
||||
});
|
||||
test("--redact refuses a replacement body instead of dropping it", () => {
|
||||
const id = log('{"decision":"redact-target","scope":"repo","source":"user"}').out;
|
||||
let code = 0;
|
||||
try {
|
||||
logFlag(`--redact ${id} '{"decision":"would-be-lost","scope":"repo","source":"user"}'`);
|
||||
} catch (e: any) {
|
||||
code = e.status || 1;
|
||||
}
|
||||
expect(code).toBe(1);
|
||||
expect(search()).toContain("redact-target"); // nothing happened at all
|
||||
});
|
||||
});
|
||||
|
||||
describe("gstack-decision-search", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* Free-tier (~50ms total). Runs in `bun test`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterAll } from "bun:test";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, chmodSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
@@ -338,7 +338,11 @@ describe("withErrorContext", () => {
|
||||
process.env.GSTACK_HOME = testHome;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// afterEach, not afterAll: the save happens in beforeEach, so an afterAll
|
||||
// restore would put back the PREVIOUS test's temp dir (the last beforeEach
|
||||
// overwrote savedHome) and leak a gstack-test-home-* dir into every test
|
||||
// file that runs after this one in the same bun process.
|
||||
afterEach(() => {
|
||||
if (savedHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = savedHome;
|
||||
});
|
||||
@@ -414,7 +418,13 @@ describe("detectEngineTier", () => {
|
||||
process.env.HOME = testHome;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// afterEach, not afterAll: the save happens in beforeEach, so an afterAll
|
||||
// restore would put back the PREVIOUS test's gstack-test-engine-* temp dir
|
||||
// (the last beforeEach overwrote the saved values). That leaked
|
||||
// HOME/GSTACK_HOME/PATH into every test file that ran after this one in the
|
||||
// same bun process — child processes then looked for Playwright's Chromium
|
||||
// cache and ~/.gstack config under a throwaway temp HOME.
|
||||
afterEach(() => {
|
||||
if (savedHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = savedHome;
|
||||
if (savedGbrainHome === undefined) delete process.env.GBRAIN_HOME;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// when the relevant CLI isn't available).
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -221,6 +222,104 @@ describe("resolveVersionPath (monorepo VERSION-path support)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Fixture-repo coverage for the default-base detection chain in parseArgs:
|
||||
// origin/HEAD symbolic-ref → origin/main probe → origin/master probe →
|
||||
// literal "main". No --base and no --current-version are passed, so the
|
||||
// detected base is observable through base_version: each fixture branch
|
||||
// carries a distinct VERSION and readBaseVersion does
|
||||
// `git show origin/<detected-base>:VERSION`.
|
||||
describe("default-base detection (no --base)", () => {
|
||||
const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-next-version");
|
||||
// Point git at a nonexistent global/system config so operator settings
|
||||
// (init.defaultBranch, commit.gpgsign, hooks, ...) can't leak into fixtures.
|
||||
const noCfg = join(mkdtempSync(join(tmpdir(), "nextver-gitcfg-")), "empty");
|
||||
const GIT_ENV = {
|
||||
...process.env,
|
||||
GIT_CONFIG_GLOBAL: noCfg,
|
||||
GIT_CONFIG_SYSTEM: noCfg,
|
||||
GIT_AUTHOR_NAME: "fixture",
|
||||
GIT_AUTHOR_EMAIL: "fixture@example.com",
|
||||
GIT_COMMITTER_NAME: "fixture",
|
||||
GIT_COMMITTER_EMAIL: "fixture@example.com",
|
||||
};
|
||||
|
||||
function git(cwd: string, ...args: string[]): void {
|
||||
execFileSync("git", args, { cwd, env: GIT_ENV, stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
// Origin repo whose branches each hold a distinct VERSION, plus a clone
|
||||
// (the clone is the repo the CLI runs in).
|
||||
function makeClone(branches: Array<[name: string, version: string]>): { root: string; clone: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "nextver-base-"));
|
||||
const origin = join(root, "origin");
|
||||
mkdirSync(origin);
|
||||
git(origin, "init", "-q", "-b", branches[0][0]);
|
||||
for (let i = 0; i < branches.length; i++) {
|
||||
const [name, version] = branches[i];
|
||||
if (i > 0) git(origin, "checkout", "-q", "-b", name);
|
||||
writeFileSync(join(origin, "VERSION"), `${version}\n`);
|
||||
git(origin, "add", "VERSION");
|
||||
git(origin, "commit", "-q", "--no-gpg-sign", "-m", `VERSION ${version}`);
|
||||
}
|
||||
git(root, "clone", "-q", "origin", "clone");
|
||||
return { root, clone: join(root, "clone") };
|
||||
}
|
||||
|
||||
function runWithoutBase(cwd: string): { exitCode: number; parsed: any } {
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", SCRIPT, "--bump", "patch", "--workspace-root", "null"],
|
||||
{ cwd },
|
||||
);
|
||||
const out = new TextDecoder().decode(proc.stdout);
|
||||
return { exitCode: proc.exitCode, parsed: JSON.parse(out) };
|
||||
}
|
||||
|
||||
test("origin/HEAD symbolic-ref wins — resolves trunk even though origin/main exists", () => {
|
||||
const { root, clone } = makeClone([["main", "1.1.1.1"], ["trunk", "2.2.2.2"]]);
|
||||
try {
|
||||
git(clone, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk");
|
||||
const { exitCode, parsed } = runWithoutBase(clone);
|
||||
expect(exitCode).toBe(0);
|
||||
// trunk's VERSION, not main's — the rev-parse probes never ran.
|
||||
expect(parsed.base_version).toBe("2.2.2.2");
|
||||
expect(parsed.version).toBe("2.2.3.0");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("no origin/HEAD, no origin/main: the origin/master probe resolves master", () => {
|
||||
const { root, clone } = makeClone([["master", "3.3.3.3"]]);
|
||||
try {
|
||||
// Plain clones that never ran `git remote set-head` have no origin/HEAD.
|
||||
git(clone, "remote", "set-head", "origin", "--delete");
|
||||
const { exitCode, parsed } = runWithoutBase(clone);
|
||||
expect(exitCode).toBe(0);
|
||||
// Read at origin/master. The "main" literal fallback would have warned
|
||||
// and assumed 0.0.0.0 instead.
|
||||
expect(parsed.base_version).toBe("3.3.3.3");
|
||||
expect(parsed.version).toBe("3.3.4.0");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("neither origin/HEAD nor main/master: falls back to the 'main' literal", () => {
|
||||
const { root, clone } = makeClone([["develop", "4.4.4.4"]]);
|
||||
try {
|
||||
git(clone, "remote", "set-head", "origin", "--delete");
|
||||
const { exitCode, parsed } = runWithoutBase(clone);
|
||||
expect(exitCode).toBe(0);
|
||||
// base = literal "main"; origin/main doesn't exist, so readBaseVersion
|
||||
// warns and assumes 0.0.0.0 — which pins WHICH base the fallback chose.
|
||||
expect(parsed.base_version).toBe("0.0.0.0");
|
||||
expect(parsed.warnings.join("\n")).toContain("origin/main");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
// Integration smoke — only runs if gh is available and authenticated. Confirms
|
||||
// the CLI executes end-to-end against real APIs without crashing.
|
||||
describe("integration (smoke)", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* zero rendering loss. The TTY rendering layer is identical for fat and slim
|
||||
* skills, so it is not where token-reduction degradation can hide.
|
||||
*/
|
||||
import { resolveEvalModel } from '../../lib/eval-model';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
@@ -191,7 +192,7 @@ This is a capture test, not an interactive session. Skip any system-audit / envi
|
||||
timeout: 240_000,
|
||||
testName: opts.testName,
|
||||
runId: opts.runId,
|
||||
model: opts.model ?? 'claude-sonnet-4-6', // D1a: align with session-runner default; Opus is opt-in
|
||||
model: resolveEvalModel('capture', opts.model),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -253,7 +254,7 @@ Rules for this run:
|
||||
timeout: opts.timeout ?? 300_000,
|
||||
testName: opts.testName,
|
||||
runId: opts.runId,
|
||||
model: opts.model ?? 'claude-sonnet-4-6', // D1a: align with session-runner default; Opus is opt-in
|
||||
model: resolveEvalModel('capture', opts.model),
|
||||
});
|
||||
|
||||
const readSections = new Set<string>();
|
||||
@@ -334,7 +335,7 @@ Write the verbatim text of that AskUserQuestion (the full decision brief: title,
|
||||
timeout: 240_000,
|
||||
testName: opts.testName,
|
||||
runId: opts.runId,
|
||||
model: opts.model ?? 'claude-sonnet-4-6', // D1a: align with session-runner default; Opus is opt-in
|
||||
model: resolveEvalModel('capture', opts.model),
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -99,6 +99,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
ship: {
|
||||
skill: 'ship',
|
||||
expectedSections: [
|
||||
'apple-release.md',
|
||||
'tests.md',
|
||||
'test-coverage.md',
|
||||
'plan-completion.md',
|
||||
@@ -129,7 +130,16 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
minUnionBytes: 120_000,
|
||||
mustContain: ['VERSION', 'CHANGELOG', 'review', 'merge', 'PR'],
|
||||
// v1.58.5.0: pre-push-guard install (#2077) stacks on the shared first-run-guidance preamble.
|
||||
maxSizeRatio: 1.08,
|
||||
// Fork port wave 2: multi-ecosystem test-detection evidence (Django/JVM
|
||||
// markers, test-file census — e3259078 port) + the #1079 gh pr edit REST
|
||||
// fallback grew the union to 1.090x; the third-party web-actions
|
||||
// contract (consent-gated browser drive for API-key registration etc.)
|
||||
// adds ~2.3KB inline judgment, measured 1.103x. The Apple release
|
||||
// adapter (14.8KB carved section, 21 live releases of judgment — the
|
||||
// wave's headline capability) grows the union to 1.195x. Deliberate:
|
||||
// the section is on-demand (loads only for Apple store targets), so
|
||||
// per-invocation cost for non-iOS ships is one manifest line.
|
||||
maxSizeRatio: 1.22,
|
||||
},
|
||||
'plan-ceo-review': {
|
||||
skill: 'plan-ceo-review',
|
||||
@@ -144,9 +154,10 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
},
|
||||
behavioral: 'external',
|
||||
externalTest: 'test/skill-e2e-plan-ceo-review-section-loading.test.ts',
|
||||
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
|
||||
// the skeleton at 90,280 B; +1 KB headroom.
|
||||
maxSkeletonBytes: 91_000,
|
||||
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
|
||||
// Fork port wave 2 (#703): the repo-doc-preference block in the design
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
maxSkeletonBytes: 92_500, // v1.64+v1.65 merge: both waves' preamble growth; measured 92,004
|
||||
minUnionBytes: 80_000,
|
||||
mustContain: ['SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'HOLD SCOPE', 'SCOPE REDUCTION'],
|
||||
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
|
||||
@@ -167,9 +178,10 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
behavioral: 'plan',
|
||||
// v1.2.0 activation lift (shared first-run-guidance preamble) + #2077 ask-first scope gate.
|
||||
// +~1 KB: plan-mode auto-select-B scope-gate exceptions (2026-08).
|
||||
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
|
||||
// the skeleton at 68,163 B; +~1 KB headroom.
|
||||
maxSkeletonBytes: 69_000,
|
||||
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
|
||||
// Fork port wave 2 (#703): the repo-doc-preference block in the design
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
maxSkeletonBytes: 70_000, // measured 68,780
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['Architecture', 'Code Quality', 'Test', 'Performance'],
|
||||
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback + the
|
||||
@@ -180,7 +192,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// 1.08 → 1.10: the scope-gate exceptions block (+ its adversarial-review
|
||||
// hardening: host-anchored mode signal, precedence, passing-mention
|
||||
// guards) and the plan-mode preamble reword land the union at 1.092.
|
||||
maxSizeRatio: 1.10,
|
||||
maxSizeRatio: 1.12, // measured 1.103
|
||||
},
|
||||
'plan-design-review': {
|
||||
skill: 'plan-design-review',
|
||||
@@ -198,13 +210,14 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift (shared first-run-guidance preamble) + #2077 ask-first scope gate.
|
||||
// +~1.3 KB: plan-mode auto-select-B scope-gate exceptions (2026-08).
|
||||
// +~340 B: telemetry --error-message/--failed-step flags + prose in the
|
||||
// shared completion-status preamble (PR #769, 2026-08); this skill was the
|
||||
// closest to its ceiling (landed 89040 / ratio 1.072).
|
||||
maxSkeletonBytes: 89_400,
|
||||
// Fork port wave 2 (D1): evidence directive adds ~0.45KB to every
|
||||
// tier-2+ skeleton (measured 89,184). Main's v1.64.0.0 adds ~340 B more
|
||||
// (telemetry --error-message/--failed-step preamble prose, PR #769).
|
||||
// Budget covers the sum of both waves.
|
||||
maxSkeletonBytes: 91_000,
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['design', 'visual'],
|
||||
maxSizeRatio: 1.08,
|
||||
maxSizeRatio: 1.12, // D1 1.104 + main's ~0.008
|
||||
},
|
||||
'plan-devex-review': {
|
||||
skill: 'plan-devex-review',
|
||||
@@ -221,7 +234,9 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// +Conductor AUQ-default-prose rule + one-way/destructive prose safety +
|
||||
// continuation protocol in the always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
maxSkeletonBytes: 80_000,
|
||||
// Fork port wave 2 (#703): the repo-doc-preference block in the design
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
maxSkeletonBytes: 82_000, // measured 80,493
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['developer experience', 'Getting Started'],
|
||||
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
|
||||
@@ -244,12 +259,15 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
behavioral: 'prompt',
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble,
|
||||
// plus the P1 office-hours closing handoff (AUQ that launches the next skill).
|
||||
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
|
||||
// the skeleton at 98,193 B; +~1 KB headroom.
|
||||
maxSkeletonBytes: 99_000,
|
||||
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
|
||||
// Fork port wave 2: the third-party web-actions contract sits inline
|
||||
// (judgment must be visible before the workflow directs the user to a
|
||||
// vendor site), plus the #703 dual-write + repo-doc-preference block and
|
||||
// the #538 opt-out + D1 evidence directive — ratio 1.104 measured.
|
||||
maxSkeletonBytes: 101_000,
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['design doc', 'problem statement'],
|
||||
maxSizeRatio: 1.07,
|
||||
maxSizeRatio: 1.12,
|
||||
},
|
||||
'document-release': {
|
||||
skill: 'document-release',
|
||||
@@ -267,7 +285,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
|
||||
// always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
maxSkeletonBytes: 56_000,
|
||||
maxSkeletonBytes: 56_500, // v1.64+v1.65 merge; measured 56,044
|
||||
minUnionBytes: 55_000,
|
||||
mustContain: ['CHANGELOG', 'Diataxis', 'coverage'],
|
||||
// Two intentional additions stack on this small skill: the AUQ-failure prose
|
||||
@@ -295,6 +313,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
|
||||
// always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
|
||||
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
|
||||
// the skeleton at 69,022 B; +~1 KB headroom.
|
||||
maxSkeletonBytes: 70_000,
|
||||
@@ -303,7 +322,8 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback ~2KB +
|
||||
// the cross-session decision-memory nudge) lands this carved skeleton just over
|
||||
// the strict 1.05; headroom for the shared preamble additions.
|
||||
maxSizeRatio: 1.07,
|
||||
// v1.64+v1.65 merge sums both waves' preamble growth; measured 1.073.
|
||||
maxSizeRatio: 1.08,
|
||||
},
|
||||
cso: {
|
||||
skill: 'cso',
|
||||
@@ -336,13 +356,14 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
|
||||
// always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
maxSkeletonBytes: 75_000,
|
||||
maxSkeletonBytes: 75_800, // v1.64+v1.65 merge; measured 75,364
|
||||
minUnionBytes: 72_000,
|
||||
mustContain: ['OWASP', 'STRIDE', 'daily', 'comprehensive', 'verif'],
|
||||
// cso keeps its mode-dispatch + FP-filtering phases always-loaded, so the
|
||||
// cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback ~2KB + the
|
||||
// decision-memory nudge) lands it just over 1.05; headroom for the shared additions.
|
||||
maxSizeRatio: 1.07,
|
||||
// v1.64+v1.65 merge sums both waves' preamble growth; measured 1.073.
|
||||
maxSizeRatio: 1.08,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
* tests don't need it).
|
||||
*/
|
||||
|
||||
import { resolveEvalModel } from '../../lib/eval-model';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -459,7 +460,7 @@ ${tail}
|
||||
// below resolveClaudeBinary(), breaking under hermetic PATHs.
|
||||
const result = nodeSpawnSync(
|
||||
resolveClaudeBinary() ?? 'claude',
|
||||
['-p', '--model', 'claude-haiku-4-5', '--max-turns', '1'],
|
||||
['-p', '--model', resolveEvalModel('warmup'), '--max-turns', '1'],
|
||||
{
|
||||
input: prompt,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -1256,7 +1257,15 @@ export const ceoStep0Boundary: Step0BoundaryPredicate = (fp) =>
|
||||
export const engStep0Boundary: Step0BoundaryPredicate = (fp) =>
|
||||
/scope reduction recommendation|cross[\s-]?project learnings/i.test(
|
||||
fp.promptSnippet,
|
||||
);
|
||||
) ||
|
||||
// plan-eng-review's Step 0 may legitimately end with NO scope-reduction /
|
||||
// learnings AUQ. When it does, the first answered review-phase question —
|
||||
// tagged <gstack-qid:plan-eng-review-...> ({skill}-{slug} convention) —
|
||||
// must fire the boundary, or every per-finding AUQ stays classified
|
||||
// preReview and the multi-finding batching counter reads 0. Anchor allows
|
||||
// the skill-name prefix; live qids observed: plan-eng-review-jitter,
|
||||
// plan-eng-review-idempotency, plan-eng-review-todos-e2e-concurrent.
|
||||
/gstack-qid:\s*(?:plan-)?eng-review-/i.test(fp.promptSnippet);
|
||||
|
||||
export const designStep0Boundary: Step0BoundaryPredicate = (fp) =>
|
||||
/design system|design posture|design score|first dimension/i.test(
|
||||
|
||||
@@ -214,7 +214,9 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
|
||||
// codexPreflight() block (install + auth tri-state + CODEX_MODE branch prose),
|
||||
// landing ~6.3% over the v1.53.0.0 baseline. Intentional: it adds proper
|
||||
// not-installed vs not-authed handling, not slop.
|
||||
maxSizeRatio: 1.08,
|
||||
// v1.64+v1.65 merge: both waves grew the shared preamble (evidence
|
||||
// directive + telemetry failure flags); measured 1.094.
|
||||
maxSizeRatio: 1.10,
|
||||
minBytes: 70_000,
|
||||
},
|
||||
{
|
||||
@@ -223,7 +225,11 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
|
||||
mustHaveHeadings: ['## Preamble', '## When to invoke'],
|
||||
// v1.2.0 activation lift: the unified first-run-guidance section (P4 scaffold +
|
||||
// P3 loop tip) is added to every skill's shared preamble — intentional, ~1KB.
|
||||
maxSizeRatio: 1.07,
|
||||
// Fork port wave 2: the shared coverage-audit detection block gained the
|
||||
// multi-ecosystem markers (Django/JVM, script/target/test-file census —
|
||||
// e3259078 port); measured 1.111x. v1.64+v1.65 merge sums both waves'
|
||||
// preamble growth; measured 1.125.
|
||||
maxSizeRatio: 1.13,
|
||||
minBytes: 50_000,
|
||||
},
|
||||
{
|
||||
@@ -237,7 +243,9 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
|
||||
// 1.09 → 1.10: the plan-mode preamble reword (scope-gate auto-select-B
|
||||
// change) adds ~250 B to every skill's shared preamble; investigate was
|
||||
// the closest to its ceiling (landed 1.092).
|
||||
maxSizeRatio: 1.10,
|
||||
// Fork port wave 2 (D1): the evidence-before-claimed-limitations preamble
|
||||
// directive adds ~0.45KB to every tier-2+ skill. Measured values noted.
|
||||
maxSizeRatio: 1.12, // D1 measured
|
||||
minBytes: 30_000,
|
||||
},
|
||||
{
|
||||
@@ -245,7 +253,9 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
|
||||
mustContain: ['ceo', 'eng', 'design'],
|
||||
mustHaveHeadings: ['## Preamble', '## When to invoke'],
|
||||
// v1.2.0 activation lift: shared first-run-guidance preamble section.
|
||||
maxSizeRatio: 1.07,
|
||||
// Fork port wave 2 (D1): the evidence-before-claimed-limitations preamble
|
||||
// directive adds ~0.45KB to every tier-2+ skill. Measured values noted.
|
||||
maxSizeRatio: 1.09, // D1 measured
|
||||
minBytes: 70_000,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ProviderAdapter, RunOpts, RunResult, AvailabilityCheck } from './types';
|
||||
import { estimateCostUsd } from '../pricing';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { execFileSync, spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -25,11 +25,30 @@ export class ClaudeAdapter implements ProviderAdapter {
|
||||
if (!resolved) {
|
||||
return { ok: false, reason: 'claude CLI not found on PATH. Install from https://claude.ai/download or npm i -g @anthropic-ai/claude-code (or set GSTACK_CLAUDE_BIN)' };
|
||||
}
|
||||
// Auth sniff: ~/.claude/.credentials.json OR ANTHROPIC_API_KEY
|
||||
// Auth sniff: ~/.claude/.credentials.json OR ANTHROPIC_API_KEY OR (macOS)
|
||||
// the Keychain entry subscription installs use instead of the creds file.
|
||||
// #1890: the default macOS install stores OAuth under the generic-password
|
||||
// service "Claude Code-credentials" and never writes .credentials.json,
|
||||
// so the file-or-env sniff reported "No Claude auth found" while
|
||||
// `claude -p` worked fine. Metadata probe only (no -w — never reads the
|
||||
// secret), and any failure of `security` itself falls through to the
|
||||
// not-found reason rather than throwing.
|
||||
const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const hasCreds = fs.existsSync(credsPath);
|
||||
const hasKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
if (!hasCreds && !hasKey) {
|
||||
let hasKeychain = false;
|
||||
if (!hasCreds && !hasKey && process.platform === 'darwin') {
|
||||
try {
|
||||
const probe = spawnSync('security', ['find-generic-password', '-s', 'Claude Code-credentials'], {
|
||||
stdio: 'ignore',
|
||||
timeout: 5000,
|
||||
});
|
||||
hasKeychain = probe.status === 0;
|
||||
} catch {
|
||||
hasKeychain = false;
|
||||
}
|
||||
}
|
||||
if (!hasCreds && !hasKey && !hasKeychain) {
|
||||
return { ok: false, reason: 'No Claude auth found. Log in via `claude` interactive session, or export ANTHROPIC_API_KEY.' };
|
||||
}
|
||||
return { ok: true };
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* StateServer hardening pins (fork port wave 2, B3 + B5).
|
||||
*
|
||||
* B3: the boot token must never appear in an os_log statement. The daemon
|
||||
* reads it from the 0600 app-container file (copyFileFromAppContainer in
|
||||
* tunnel-bootstrap.ts); the old `token=\(self.bootToken, privacy: .public)`
|
||||
* announce line had NO consumer and handed a live credential to anything
|
||||
* reading the unified log during the launch window.
|
||||
*
|
||||
* B5: the IPv4 listener has no CoreDevice tunnel path, so it must bind to
|
||||
* loopback at the socket level (requiredLocalEndpoint 127.0.0.1), not rely
|
||||
* solely on the per-connection peer check. IPv6 keeps the wildcard bind for
|
||||
* CoreDevice ULA peers by design.
|
||||
*
|
||||
* Pinned on BOTH the generated template and the fixture app copy so neither
|
||||
* can drift back independently.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const COPIES = [
|
||||
"ios-qa/templates/StateServer.swift.template",
|
||||
"test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift",
|
||||
];
|
||||
|
||||
describe.each(COPIES)("StateServer hardening — %s", (rel) => {
|
||||
const src = readFileSync(join(ROOT, rel), "utf-8");
|
||||
|
||||
test("no os_log statement interpolates the boot token (B3)", () => {
|
||||
const logLines = src.split("\n").filter((l) => /logger\.(notice|info|error|debug|log)/.test(l));
|
||||
for (const line of logLines) {
|
||||
expect(line).not.toContain("bootToken");
|
||||
}
|
||||
// The bootstrap announce survives (diagnostics), token-free.
|
||||
expect(src).toContain('gstack-ios-qa-bootstrap port=');
|
||||
expect(src).not.toContain("gstack-ios-qa-bootstrap token=");
|
||||
});
|
||||
|
||||
test("IPv4 listener binds loopback at the socket level (B5)", () => {
|
||||
expect(src).toContain("requiredLocalEndpoint");
|
||||
expect(src).toContain('NWEndpoint.Host("127.0.0.1")');
|
||||
// IPv6 wildcard + peer-check path must survive (CoreDevice tunnel peers).
|
||||
expect(src).toMatch(/case \.ipv6:\s*\n\s*listener = try NWListener\(using: params, on:/);
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { execFileSync } from "child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, symlinkSync, realpathSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
@@ -45,11 +45,70 @@ describe("gstack-memory-ingest: gbrain import must not be filtered by .gitignore
|
||||
const stripped = stripComments(readFileSync(SOURCE_PATH, "utf-8"));
|
||||
// Match the spawn call's argument array and assert both the subcommand
|
||||
// and the flag live in it, so the flag can't drift onto another call.
|
||||
const call = stripped.match(/spawnGbrainAsync\(\s*\[[^\]]*"import"[^\]]*\]/s);
|
||||
// [\s\S]*? bridges the conditional-spread's nested brackets (main's
|
||||
// capability-probed --include-gitignored merged with our baseEnv defense).
|
||||
const call = stripped.match(/spawnGbrainAsync\(\s*\[[\s\S]*?"import"[\s\S]*?\]/s);
|
||||
expect(call).not.toBeNull();
|
||||
expect(call![0]).toContain("--include-gitignored");
|
||||
});
|
||||
|
||||
it("sets a realpath'd GIT_CEILING_DIRECTORIES on the import child (defense-in-depth)", () => {
|
||||
const stripped = stripComments(readFileSync(SOURCE_PATH, "utf-8"));
|
||||
// Second #2144 layer: the ceiling env must be built from the staging
|
||||
// dir's REAL parent path and merged into the spawn's baseEnv, so a
|
||||
// git-enumerating collector fails out of the git fast path even when
|
||||
// the flag's semantics drift, and symlinked staging paths still match.
|
||||
expect(stripped).toContain("GIT_CEILING_DIRECTORIES");
|
||||
expect(stripped).toMatch(/realpathSync\(dirname\(stagingDir\)\)/);
|
||||
const call = stripped.match(/spawnGbrainAsync\(\s*\[[\s\S]*?"import"[\s\S]*?\]\s*,\s*\{\s*baseEnv\s*\}/s);
|
||||
expect(call).not.toBeNull();
|
||||
});
|
||||
|
||||
it("proves the ceiling stops git discovery from the staging dir — including through a symlink", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-ingest-ceiling-"));
|
||||
try {
|
||||
const git = (args: string[], cwd: string, env?: NodeJS.ProcessEnv) =>
|
||||
execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
// ~/.gstack shape: a git repo whose root ignores everything, with the
|
||||
// staging dir as a direct child.
|
||||
const home = join(dir, "gstack-home");
|
||||
mkdirSync(home, { recursive: true });
|
||||
git(["init", "-q", "."], home);
|
||||
writeFileSync(join(home, ".gitignore"), "*\n", "utf-8");
|
||||
const staging = join(home, ".staging-ingest-12345-1700000000000");
|
||||
mkdirSync(staging, { recursive: true });
|
||||
|
||||
// Without a ceiling: discovery from the staging dir finds the repo —
|
||||
// this is the git fast path that collects zero files.
|
||||
const found = git(["rev-parse", "--show-toplevel"], staging).trim();
|
||||
expect(realpathSync(found)).toBe(realpathSync(home));
|
||||
|
||||
// With the ceiling at the staging dir's REAL parent: discovery fails,
|
||||
// which is exactly what pushes a collector onto its plain FS walk.
|
||||
const ceiling = realpathSync(home);
|
||||
expect(() =>
|
||||
git(["rev-parse", "--show-toplevel"], staging, { GIT_CEILING_DIRECTORIES: ceiling }),
|
||||
).toThrow();
|
||||
|
||||
// Symlink variant (the OV4 trap): reach the same staging dir through a
|
||||
// symlinked path. A realpath'd ceiling still stops discovery.
|
||||
const linked = join(dir, "linked-home");
|
||||
symlinkSync(home, linked);
|
||||
const stagingViaLink = join(linked, ".staging-ingest-12345-1700000000000");
|
||||
expect(() =>
|
||||
git(["rev-parse", "--show-toplevel"], stagingViaLink, {
|
||||
GIT_CEILING_DIRECTORIES: ceiling,
|
||||
}),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("demonstrates the collision: an ignore-everything root hides staged pages", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-ingest-gitignore-"));
|
||||
try {
|
||||
|
||||
@@ -71,7 +71,11 @@ function run(extraEnv: Record<string, string> = {}, input = ''): { code: number;
|
||||
PATH: `${fakeBinDir}:${path.join(ROOT, 'bin')}:/usr/bin:/bin:/opt/homebrew/bin`,
|
||||
HOME: tmpHome,
|
||||
USER: 'testuser',
|
||||
// Disable interactive prompt: empty stdin = treat as non-interactive.
|
||||
// Empty stdin = non-interactive. Since #1383 the script SKIPS by
|
||||
// default in that context (a remote repo rename is consent-shaped);
|
||||
// the harness opts in explicitly, simulating a consenting unattended
|
||||
// run. The default-skip contract has its own test below.
|
||||
GSTACK_MIGRATE_ASSUME_YES: '1',
|
||||
...extraEnv,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
@@ -168,6 +172,67 @@ describe('v1.27.0.0 migration — GitHub host (non-interactive)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1.27.0.0 migration — #1383 consent + failure-stays-pending contract', () => {
|
||||
beforeEach(() => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, '.gstack-brain-remote.txt'),
|
||||
'https://github.com/testuser/gstack-brain-testuser\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, '.gstack/config.yaml'),
|
||||
'gbrain_sync_mode: full\n'
|
||||
);
|
||||
makeFakeGh({});
|
||||
});
|
||||
|
||||
test('non-interactive without opt-in: skips for now, touches NOTHING', () => {
|
||||
const r = run({ GSTACK_MIGRATE_ASSUME_YES: '0' });
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stderr).toContain('skipping for now');
|
||||
expect(r.stderr).toContain('GSTACK_MIGRATE_ASSUME_YES=1');
|
||||
// The remediation must be REAL: a direct invocation of this script.
|
||||
// `/setup-gbrain --rerun-migration` never existed, and the runners'
|
||||
// version windows never re-select a passed migration, so "will ask
|
||||
// again next upgrade" was false.
|
||||
expect(r.stderr).toContain('v1.27.0.0.sh');
|
||||
expect(r.stderr).not.toContain('--rerun-migration');
|
||||
expect(r.stderr).not.toContain('ask again');
|
||||
// Old state untouched, nothing recorded as done.
|
||||
expect(fs.existsSync(path.join(tmpHome, '.gstack-brain-remote.txt'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpHome, '.gstack-artifacts-remote.txt'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.done'))).toBe(false);
|
||||
});
|
||||
|
||||
test('gh rename failure: step stays pending, migration exits INCOMPLETE, retry succeeds', () => {
|
||||
makeFakeGh({ renameSucceeds: false });
|
||||
const r = run();
|
||||
// Failure must be loud and the migration visibly incomplete — the old
|
||||
// behavior marked the failed step done and wrote the done touchfile,
|
||||
// permanently stranding a half-renamed install (#1383).
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.stderr).toContain('PENDING');
|
||||
expect(r.stderr).toContain('INCOMPLETE');
|
||||
// Honest remediation: direct invocation, not the nonexistent
|
||||
// /setup-gbrain --rerun-migration flag.
|
||||
expect(r.stderr).toContain('Re-run manually with:');
|
||||
expect(r.stderr).toContain('GSTACK_MIGRATE_ASSUME_YES=1');
|
||||
expect(r.stderr).not.toContain('--rerun-migration');
|
||||
expect(fs.existsSync(path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.done'))).toBe(false);
|
||||
const journal = fs.readFileSync(
|
||||
path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.journal'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(journal).not.toContain('gh_repo_renamed');
|
||||
// Later steps DID run and are journaled (independent of step 1)...
|
||||
expect(journal).toContain('remote_txt_renamed');
|
||||
// ...so a retry with working gh only redoes step 1 and completes.
|
||||
makeFakeGh({});
|
||||
const r2 = run();
|
||||
expect(r2.code).toBe(0);
|
||||
expect(fs.existsSync(path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.done'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1.27.0.0 migration — interruption resume', () => {
|
||||
beforeEach(() => {
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* v1.65.0.0 migration — remove rebrand-poisoned Chromium bundles (#2242)
|
||||
* and re-fetch a clean one, gating .done on a VERIFIED end state.
|
||||
*
|
||||
* Exercises the migration in a hermetic temp HOME + temp Playwright cache
|
||||
* (HOME / GSTACK_HOME / PLAYWRIGHT_BROWSERS_PATH all redirected) with a
|
||||
* stubbed bunx on PATH — nothing touches the real cache. Covers:
|
||||
* - whole-revision-dir removal (INSTALLATION_COMPLETE marker included, so
|
||||
* `playwright install` can't no-op with "is already downloaded")
|
||||
* - clean bundles untouched
|
||||
* - re-fetch runs from the gstack install root (repo-pinned playwright)
|
||||
* - .done only written once a Chromium executable verifiably exists
|
||||
* - failed re-fetch → warning + retry on next run (needs-refetch sentinel)
|
||||
* - stranded revision dir (markers without .app) re-triggers the re-fetch
|
||||
* - idempotent re-run after success
|
||||
* - non-Darwin early exit
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const MIGRATION = path.join(ROOT, 'gstack-upgrade', 'migrations', 'v1.65.0.0.sh');
|
||||
|
||||
let tmpHome: string;
|
||||
let fakeBinDir: string;
|
||||
let pwCache: string;
|
||||
|
||||
const POISONED_PLIST =
|
||||
'<plist><dict><key>CFBundleName</key><string>GStack Browser</string></dict></plist>';
|
||||
const CLEAN_PLIST =
|
||||
'<plist><dict><key>CFBundleName</key><string>Google Chrome for Testing</string></dict></plist>';
|
||||
|
||||
/** Build a Playwright-cache-shaped bundle: <revDir>/chrome-mac/<name>.app */
|
||||
function makeBundle(
|
||||
revDir: string,
|
||||
opts: { plist: string; withExecutable?: boolean; withMarkers?: boolean }
|
||||
): string {
|
||||
const appDir = path.join(revDir, 'chrome-mac', 'Google Chrome for Testing.app');
|
||||
const contents = path.join(appDir, 'Contents');
|
||||
fs.mkdirSync(contents, { recursive: true });
|
||||
fs.writeFileSync(path.join(contents, 'Info.plist'), opts.plist);
|
||||
if (opts.withExecutable) {
|
||||
const macos = path.join(contents, 'MacOS');
|
||||
fs.mkdirSync(macos, { recursive: true });
|
||||
fs.writeFileSync(path.join(macos, 'Google Chrome for Testing'), '#!/bin/sh\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
}
|
||||
if (opts.withMarkers ?? true) {
|
||||
fs.writeFileSync(path.join(revDir, 'INSTALLATION_COMPLETE'), '');
|
||||
fs.writeFileSync(path.join(revDir, 'DEPENDENCIES_VALIDATED'), '');
|
||||
}
|
||||
return appDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub bunx: records every invocation (args + cwd) and, unless told
|
||||
* otherwise, simulates a successful `playwright install chromium` by
|
||||
* creating a fresh revision dir with an executable in the temp cache.
|
||||
*/
|
||||
function makeFakeBunx(opts: { createsExecutable?: boolean } = {}): string {
|
||||
const creates = opts.createsExecutable ?? true;
|
||||
const callLog = path.join(fakeBinDir, 'bunx-calls.log');
|
||||
const script = `#!/bin/bash
|
||||
echo "bunx $@ (pwd=$(pwd))" >> "${callLog}"
|
||||
${
|
||||
creates
|
||||
? `FRESH="\${PLAYWRIGHT_BROWSERS_PATH}/chromium-9999/chrome-mac/Chromium.app/Contents/MacOS"
|
||||
mkdir -p "\${FRESH}"
|
||||
printf '#!/bin/sh\\n' > "\${FRESH}/Chromium"
|
||||
chmod 755 "\${FRESH}/Chromium"
|
||||
touch "\${PLAYWRIGHT_BROWSERS_PATH}/chromium-9999/INSTALLATION_COMPLETE"`
|
||||
: '# simulates an offline / failed download: no files created'
|
||||
}
|
||||
exit 0
|
||||
`;
|
||||
fs.writeFileSync(path.join(fakeBinDir, 'bunx'), script, { mode: 0o755 });
|
||||
return callLog;
|
||||
}
|
||||
|
||||
function run(extraEnv: Record<string, string> = {}): {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
} {
|
||||
const r = spawnSync(MIGRATION, [], {
|
||||
env: {
|
||||
PATH: `${fakeBinDir}:/usr/bin:/bin`,
|
||||
HOME: tmpHome,
|
||||
GSTACK_HOME: path.join(tmpHome, '.gstack'),
|
||||
PLAYWRIGHT_BROWSERS_PATH: pwCache,
|
||||
...extraEnv,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpHome,
|
||||
});
|
||||
return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
}
|
||||
|
||||
const doneFile = () => path.join(tmpHome, '.gstack', '.migrations', 'v1.65.0.0.done');
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-v1.65-'));
|
||||
fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-v1.65-fake-'));
|
||||
pwCache = path.join(tmpHome, 'pw-cache', 'ms-playwright');
|
||||
fs.mkdirSync(pwCache, { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpHome, '.gstack'), { recursive: true });
|
||||
// The migration gates on `uname -s` = Darwin; on the Linux CI runner the
|
||||
// real uname made every test early-exit with empty output. Shim Darwin so
|
||||
// the Darwin-path tests run everywhere; the non-Darwin test overwrites
|
||||
// this shim with its own Linux uname.
|
||||
fs.writeFileSync(path.join(fakeBinDir, 'uname'), '#!/bin/bash\necho Darwin\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('v1.65.0.0 migration — poisoned bundle removal', () => {
|
||||
test('poisoned revision dir removed WHOLE (markers included); clean bundle untouched; verified re-fetch → .done', () => {
|
||||
const poisonedRev = path.join(pwCache, 'chromium-1234');
|
||||
makeBundle(poisonedRev, { plist: POISONED_PLIST });
|
||||
const cleanRev = path.join(pwCache, 'chromium-5678');
|
||||
const cleanApp = makeBundle(cleanRev, { plist: CLEAN_PLIST, withExecutable: true });
|
||||
const log = makeFakeBunx();
|
||||
|
||||
const r = run();
|
||||
expect(r.code).toBe(0);
|
||||
|
||||
// The WHOLE revision dir is gone — .app AND the INSTALLATION_COMPLETE /
|
||||
// DEPENDENCIES_VALIDATED markers. Removing only the .app leaves markers
|
||||
// that make `playwright install chromium` no-op ("is already
|
||||
// downloaded"), stranding the user with NO browser + a success message.
|
||||
expect(fs.existsSync(poisonedRev)).toBe(false);
|
||||
|
||||
// Clean bundle untouched.
|
||||
expect(fs.existsSync(path.join(cleanApp, 'Contents', 'Info.plist'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(cleanRev, 'INSTALLATION_COMPLETE'))).toBe(true);
|
||||
|
||||
// Re-fetch invoked...
|
||||
const calls = fs.readFileSync(log, 'utf-8');
|
||||
expect(calls).toContain('playwright install chromium');
|
||||
// ...from the gstack install root (repo-pinned playwright version), not
|
||||
// from the arbitrary cwd the migration runner happened to use.
|
||||
const realRoot = fs.realpathSync(ROOT);
|
||||
expect(calls.includes(`pwd=${ROOT}`) || calls.includes(`pwd=${realRoot}`)).toBe(true);
|
||||
|
||||
// Verified end state → .done written.
|
||||
expect(fs.existsSync(doneFile())).toBe(true);
|
||||
});
|
||||
|
||||
test('re-fetch produces no executable → WARNING, .done NOT written; next run retries and completes', () => {
|
||||
makeBundle(path.join(pwCache, 'chromium-1234'), { plist: POISONED_PLIST });
|
||||
const log = makeFakeBunx({ createsExecutable: false });
|
||||
|
||||
const r = run();
|
||||
expect(r.code).toBe(0); // non-fatal per the migration contract
|
||||
expect(r.stderr).toContain('WARNING');
|
||||
expect(r.stderr).toContain('bunx playwright install chromium');
|
||||
// Removal succeeded but the end state is "no browser": .done must NOT
|
||||
// be written so a re-run retries instead of recording success.
|
||||
expect(fs.existsSync(doneFile())).toBe(false);
|
||||
|
||||
// Re-run with a working fetch: the pending state re-triggers the
|
||||
// re-fetch even though the scans have nothing left to remove.
|
||||
fs.rmSync(log, { force: true });
|
||||
makeFakeBunx({ createsExecutable: true });
|
||||
const r2 = run();
|
||||
expect(r2.code).toBe(0);
|
||||
expect(fs.readFileSync(log, 'utf-8')).toContain('playwright install chromium');
|
||||
expect(fs.existsSync(doneFile())).toBe(true);
|
||||
});
|
||||
|
||||
test('stranded revision dir (install markers, no .app) re-triggers removal + re-fetch', () => {
|
||||
// The state an older version of this migration could leave behind:
|
||||
// .app removed, INSTALLATION_COMPLETE still present → `playwright
|
||||
// install` no-ops while the user has no browser at all.
|
||||
const strandedRev = path.join(pwCache, 'chromium-1234');
|
||||
fs.mkdirSync(path.join(strandedRev, 'chrome-mac'), { recursive: true });
|
||||
fs.writeFileSync(path.join(strandedRev, 'INSTALLATION_COMPLETE'), '');
|
||||
const log = makeFakeBunx();
|
||||
|
||||
const r = run();
|
||||
expect(r.code).toBe(0);
|
||||
expect(fs.existsSync(strandedRev)).toBe(false);
|
||||
expect(fs.readFileSync(log, 'utf-8')).toContain('playwright install chromium');
|
||||
expect(fs.existsSync(doneFile())).toBe(true);
|
||||
});
|
||||
|
||||
test('clean cache → no-op, no bunx call, .done written', () => {
|
||||
const cleanRev = path.join(pwCache, 'chromium-5678');
|
||||
const cleanApp = makeBundle(cleanRev, { plist: CLEAN_PLIST, withExecutable: true });
|
||||
const log = makeFakeBunx();
|
||||
|
||||
const r = run();
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stderr).toContain('no-op');
|
||||
expect(fs.existsSync(log)).toBe(false); // bunx never invoked
|
||||
expect(fs.existsSync(path.join(cleanApp, 'Contents', 'Info.plist'))).toBe(true);
|
||||
expect(fs.existsSync(doneFile())).toBe(true);
|
||||
});
|
||||
|
||||
test('second run after success → silent no-op (no rescan, no bunx)', () => {
|
||||
makeBundle(path.join(pwCache, 'chromium-1234'), { plist: POISONED_PLIST });
|
||||
const log = makeFakeBunx();
|
||||
const r1 = run();
|
||||
expect(r1.code).toBe(0);
|
||||
expect(fs.existsSync(doneFile())).toBe(true);
|
||||
|
||||
fs.rmSync(log, { force: true });
|
||||
const r2 = run();
|
||||
expect(r2.code).toBe(0);
|
||||
expect(r2.stderr).toBe('');
|
||||
expect(fs.existsSync(log)).toBe(false);
|
||||
});
|
||||
|
||||
test('non-Darwin → early exit, cache untouched, no bunx, .done written', () => {
|
||||
const poisonedApp = makeBundle(path.join(pwCache, 'chromium-1234'), {
|
||||
plist: POISONED_PLIST,
|
||||
});
|
||||
makeFakeBunx();
|
||||
// The script gates on `uname -s` != Darwin; shadow uname on PATH.
|
||||
fs.writeFileSync(path.join(fakeBinDir, 'uname'), '#!/bin/bash\necho Linux\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
|
||||
const r = run();
|
||||
expect(r.code).toBe(0);
|
||||
expect(fs.existsSync(poisonedApp)).toBe(true); // nothing removed
|
||||
expect(fs.existsSync(path.join(fakeBinDir, 'bunx-calls.log'))).toBe(false);
|
||||
expect(fs.existsSync(doneFile())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Regression pin for #2091: /codex was broken on every macOS install because
|
||||
* its mktemp templates carried a suffix after the X's ("codex-err-XXXXXX.txt").
|
||||
* BSD mktemp (macOS) requires the X's to be the trailing characters of the
|
||||
* template; with a suffix it fails ("mkstemp failed ... File exists"), the
|
||||
* temp file never exists, and the skill dies before Codex ever runs.
|
||||
*
|
||||
* GNU mktemp accepts a --suffix flag but ALSO rejects inline suffixes in the
|
||||
* template argument on BusyBox, so the portable form is: X's last, no suffix.
|
||||
*
|
||||
* This scans every .tmpl (the sources of truth — generated SKILL.md files
|
||||
* follow at regen time) for the broken shape.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { execFileSync } from "child_process";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
|
||||
function trackedTmplFiles(): string[] {
|
||||
const out = execFileSync("git", ["ls-files", "*.tmpl", "**/*.tmpl"], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
return out.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
describe("mktemp portability (#2091)", () => {
|
||||
it("no .tmpl file uses a mktemp template with characters after XXXXXX", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const rel of trackedTmplFiles()) {
|
||||
const src = readFileSync(join(ROOT, rel), "utf-8");
|
||||
src.split("\n").forEach((line, i) => {
|
||||
// Broken shape: the X-run followed by a non-quote, non-whitespace,
|
||||
// non-closing character inside a mktemp invocation. X{6,} is greedy,
|
||||
// so longer X-runs (spec's XXXXXXXX) stay valid — only a genuine
|
||||
// suffix after the final X trips it.
|
||||
if (/mktemp[^\n]*X{6,}[^"'\s)X]/.test(line)) {
|
||||
offenders.push(`${rel}:${i + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("BSD-portable form actually works on this platform", () => {
|
||||
// Live sanity: the exact template shape the skills now emit.
|
||||
const tmp = process.env.TMPDIR || "/tmp";
|
||||
const created = execFileSync("mktemp", [`${tmp.replace(/\/$/, "")}/gstack-portability-XXXXXX`], {
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
expect(created.length).toBeGreaterThan(0);
|
||||
execFileSync("rm", ["-f", created]);
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,12 @@ const SCAN_PATHS = [
|
||||
'review/SKILL.md.tmpl',
|
||||
'ship/SKILL.md.tmpl',
|
||||
'test/',
|
||||
// Fork port wave 2: docs were deliberately excluded and that exact gap let
|
||||
// a dead command (gstack-brain-init) survive ~36 releases as a
|
||||
// command-not-found instruction. Docs are user-facing surface; scan them.
|
||||
'docs/',
|
||||
'README.md',
|
||||
'USING_GBRAIN_WITH_GSTACK.md',
|
||||
];
|
||||
|
||||
function grepRefs(pattern: string): string[] {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* R2 pin (fork port wave 2): the Apple release adapter loads BEFORE ship's
|
||||
* branch gate, and the non-Apple gate is byte-unchanged.
|
||||
*
|
||||
* Two failure modes this prevents: a future ship-template refactor that
|
||||
* re-blocks store releases behind "ship from a feature branch" (the exact
|
||||
* live failure the fork hit — a solo dev with a clean tree on main shipping
|
||||
* to TestFlight got aborted over branch topology), and the reverse — the
|
||||
* Apple path accidentally weakening the branch gate for normal
|
||||
* repository-landing ships.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const SKELETON = readFileSync(join(ROOT, "ship", "SKILL.md"), "utf-8");
|
||||
|
||||
const GATE_TEXT =
|
||||
'If on the base branch or the repo\'s default branch, **abort**: "You\'re on the base branch. Ship from a feature branch."';
|
||||
|
||||
describe("ship Apple gate ordering (R2)", () => {
|
||||
test("the Apple adapter read directive precedes the branch gate", () => {
|
||||
const appleRead = SKELETON.indexOf("sections/apple-release.md");
|
||||
const gate = SKELETON.indexOf(GATE_TEXT);
|
||||
expect(appleRead).toBeGreaterThan(-1);
|
||||
expect(gate).toBeGreaterThan(-1);
|
||||
expect(appleRead).toBeLessThan(gate);
|
||||
});
|
||||
|
||||
test("store distribution explicitly bypasses the branch/PR ceremony", () => {
|
||||
expect(SKELETON).toContain("Store distribution proceeds");
|
||||
expect(SKELETON).toMatch(/branch gate and repository-landing pipeline below apply ONLY to\s*\n?repository-landing asks/);
|
||||
});
|
||||
|
||||
test("the non-Apple branch gate is byte-unchanged and appears exactly once", () => {
|
||||
const first = SKELETON.indexOf(GATE_TEXT);
|
||||
expect(first).toBeGreaterThan(-1);
|
||||
expect(SKELETON.indexOf(GATE_TEXT, first + 1)).toBe(-1);
|
||||
});
|
||||
|
||||
test("the adapter section exists in the union with its battle-tested spine", () => {
|
||||
const section = readFileSync(join(ROOT, "ship", "sections", "apple-release.md"), "utf-8");
|
||||
for (const anchor of [
|
||||
"one authorization moment",
|
||||
"fastlane spaceauth",
|
||||
"iris/v1/apiKeys",
|
||||
"appPriceSchedules",
|
||||
"CLASSIFY the error before touching credentials",
|
||||
"Never abort an App Store release over branch topology",
|
||||
]) {
|
||||
expect(section).toContain(anchor);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Regression: /ship Step 4 test-framework detection was blind to Django.
|
||||
*
|
||||
* A real Django project (manage.py + <app>/tests.py, green `python manage.py
|
||||
* test`, no pytest.ini and no tests/ directory) read as "no test framework",
|
||||
* so /ship bootstrapped pytest on top of a working suite. Same blindness hit
|
||||
* any config-less-but-tested project (Go *_test.go, in-source Rust #[test],
|
||||
* package.json with only a test script).
|
||||
*
|
||||
* These run the resolver's OWN emitted detection block against fixtures, so
|
||||
* the shell is checked, not just the prose — and the test is independent of
|
||||
* generated-SKILL.md regen state.
|
||||
*
|
||||
* Ported from time-attack/gstack commit e3259078 (GStack 2), adapted from the
|
||||
* fork's generated-markdown target to our resolver source of truth.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { generateTestBootstrap } from '../scripts/resolvers/testing';
|
||||
|
||||
const section = generateTestBootstrap({} as never);
|
||||
|
||||
/** The detection block the skill tells the agent to run (first bash fence). */
|
||||
function detectionScript(): string {
|
||||
const open = section.indexOf('```bash\n');
|
||||
expect(open).toBeGreaterThan(-1);
|
||||
const start = open + '```bash\n'.length;
|
||||
const end = section.indexOf('```', start);
|
||||
return section.slice(start, end);
|
||||
}
|
||||
|
||||
/** Run the detection block in a throwaway git repo laid out by `files`. */
|
||||
function detect(files: Record<string, string>): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-detect-'));
|
||||
try {
|
||||
for (const [rel, body] of Object.entries(files)) {
|
||||
const abs = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body);
|
||||
}
|
||||
const script = path.join(dir, '.detect.sh');
|
||||
fs.writeFileSync(script, detectionScript());
|
||||
const git = (...args: string[]) => execFileSync('git', args, { cwd: dir });
|
||||
git('init', '-q', '.');
|
||||
git('add', '-A');
|
||||
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture');
|
||||
// The block's last line is a `[ -f marker ] && echo`, so a clean project
|
||||
// exits 1 by design — read stdout, don't trust the status.
|
||||
return execFileSync('bash', [script], { cwd: dir, encoding: 'utf-8' });
|
||||
} catch (err: unknown) {
|
||||
const e = err as { stdout?: string };
|
||||
if (typeof e.stdout === 'string') return e.stdout;
|
||||
throw err;
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const testFileCount = (out: string): number =>
|
||||
Number(/^TESTFILES:(\d+)$/m.exec(out)?.[1] ?? -1);
|
||||
|
||||
describe('/ship Step 4 detection is multi-ecosystem', () => {
|
||||
test('Django project reports manage.py and its existing tests', () => {
|
||||
const out = detect({
|
||||
'manage.py': '#!/usr/bin/env python\n',
|
||||
'requirements.txt': 'Django==5.0\n',
|
||||
'polls/tests.py': 'from django.test import TestCase\n',
|
||||
});
|
||||
expect(out).toContain('MARKER:manage.py');
|
||||
expect(out).toContain('FRAMEWORK:django');
|
||||
expect(testFileCount(out)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('config-less projects with tests still report test evidence', () => {
|
||||
expect(testFileCount(detect({
|
||||
'go.mod': 'module x\n',
|
||||
'x_test.go': 'package x\n',
|
||||
}))).toBeGreaterThan(0);
|
||||
|
||||
const node = detect({
|
||||
'package.json': '{"name":"n","scripts":{"test":"node --test"}}\n',
|
||||
});
|
||||
expect(node).toContain('SCRIPT:package.json test');
|
||||
|
||||
const rust = detect({
|
||||
'Cargo.toml': '[package]\nname="x"\n',
|
||||
'src/lib.rs': '#[cfg(test)]\nmod t{ #[test] fn x(){} }\n',
|
||||
});
|
||||
expect(rust).toContain('TESTS:rust in-source');
|
||||
});
|
||||
|
||||
test('a genuinely untested project reports no test evidence', () => {
|
||||
const out = detect({ 'go.mod': 'module x\n', 'x.go': 'package x\n' });
|
||||
expect(out).toContain('RUNTIME:go');
|
||||
expect(testFileCount(out)).toBe(0);
|
||||
expect(out).not.toContain('TESTS:');
|
||||
});
|
||||
|
||||
test('every ecosystem marker maps to a command to OFFER, not to run blind', () => {
|
||||
expect(section).toContain('never a command to run blind');
|
||||
for (const marker of ['manage.py', 'go.mod', 'Cargo.toml', 'pom.xml',
|
||||
'build.gradle', 'mix.exs', 'composer.json', 'Gemfile', 'pytest.ini']) {
|
||||
expect(section).toContain(marker);
|
||||
}
|
||||
// The ask-and-persist contract, not a hardcoded project command.
|
||||
expect(section).toContain('AskUserQuestion');
|
||||
expect(section).toContain('persist the answer to CLAUDE.md');
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,7 @@ describeE2E('AUTO_DECIDE opt-in preserved under Conductor flags (periodic)', ()
|
||||
skillName: 'plan-ceo-review',
|
||||
inPlanMode: true,
|
||||
extraArgs: ['--disallowedTools', 'AskUserQuestion'],
|
||||
timeoutMs: 300_000,
|
||||
timeoutMs: 540_000,
|
||||
env: { GSTACK_HOME: tmpHome, CONDUCTOR_WORKSPACE_PATH: tmpHome },
|
||||
});
|
||||
|
||||
@@ -135,5 +135,5 @@ describeE2E('AUTO_DECIDE opt-in preserved under Conductor flags (periodic)', ()
|
||||
} finally {
|
||||
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
}, 360_000);
|
||||
}, 660_000);
|
||||
});
|
||||
|
||||
@@ -34,6 +34,12 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
// Stage a fresh GSTACK_HOME with artifacts_sync_mode_prompted=false.
|
||||
const gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-gstack-'));
|
||||
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-bin-'));
|
||||
// Fresh HOME with NO ~/.claude.json: on a machine where gbrain is
|
||||
// registered type=http, the preamble's remote-mode detection reads the
|
||||
// operator's ~/.claude.json and echoes "ARTIFACTS_SYNC: remote-mode" —
|
||||
// and the local privacy gate legitimately never fires. An empty HOME
|
||||
// makes the detection find nothing.
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-home-'));
|
||||
|
||||
// Seed the config so the gate's condition passes.
|
||||
fs.writeFileSync(
|
||||
@@ -59,12 +65,16 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
|
||||
const binary = resolveClaudeBinary();
|
||||
|
||||
// Ambient env mutations — restored in finally so other tests in the file
|
||||
// don't inherit them.
|
||||
const origGstackHome = process.env.GSTACK_HOME;
|
||||
const origPath = process.env.PATH;
|
||||
process.env.GSTACK_HOME = gstackHome;
|
||||
process.env.PATH = `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
|
||||
// Per-test env, merged LAST by the hermetic env builder (safe post-v1.39:
|
||||
// the runner always passes a COMPLETE hermetic env, so overrides can't
|
||||
// break auth). Ambient process.env.GSTACK_HOME mutation does NOT work
|
||||
// here — hermetic-env scrubs GSTACK_* and repoints GSTACK_HOME at its
|
||||
// own singleton dir, so the staged config would never reach the child.
|
||||
const childEnv = {
|
||||
GSTACK_HOME: gstackHome,
|
||||
HOME: tempHome,
|
||||
PATH: `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`,
|
||||
};
|
||||
|
||||
try {
|
||||
// Pick a small skill with the preamble and load it via Read to force
|
||||
@@ -85,12 +95,7 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
workingDirectory: gstackHome,
|
||||
maxTurns: 10,
|
||||
allowedTools: ['Read', 'Grep', 'Glob', 'Bash'],
|
||||
// NOTE: do NOT pass `env:` here. When the Agent SDK gets an explicit
|
||||
// env object, its auth pipeline doesn't pick up ANTHROPIC_API_KEY the
|
||||
// same way as when env is undefined (SDK-internal detail, verified
|
||||
// against the plan-mode-no-op test which passes no env and auths
|
||||
// cleanly). Instead, mutate process.env before the call so the SDK
|
||||
// inherits our overrides ambiently.
|
||||
env: childEnv,
|
||||
...(binary ? { pathToClaudeCodeExecutable: binary } : {}),
|
||||
canUseTool: async (toolName, input) => {
|
||||
if (toolName === 'AskUserQuestion') {
|
||||
@@ -141,13 +146,9 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
// (The preamble is supposed to be idempotent within a session.)
|
||||
expect(privacyQuestions.length).toBe(1);
|
||||
} finally {
|
||||
// Restore ambient env before other tests.
|
||||
if (origGstackHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = origGstackHome;
|
||||
if (origPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = origPath;
|
||||
fs.rmSync(gstackHome, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
@@ -155,6 +156,10 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
// Same staging, but prompted=true this time. Gate should be silent.
|
||||
const gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-'));
|
||||
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-bin-'));
|
||||
// Fresh HOME without a .claude.json — same rationale as the first test:
|
||||
// without it the operator's ~/.claude.json flips the preamble into
|
||||
// remote-mode and this negative test passes vacuously.
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-home-'));
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(gstackHome, 'config.yaml'),
|
||||
@@ -171,11 +176,13 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
|
||||
const binary = resolveClaudeBinary();
|
||||
|
||||
// Ambient env mutations (see note on the first test).
|
||||
const origGstackHome = process.env.GSTACK_HOME;
|
||||
const origPath = process.env.PATH;
|
||||
process.env.GSTACK_HOME = gstackHome;
|
||||
process.env.PATH = `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
|
||||
// Per-test env, merged LAST by the hermetic env builder (see note on the
|
||||
// first test — ambient GSTACK_HOME mutation is scrubbed by hermetic-env).
|
||||
const childEnv = {
|
||||
GSTACK_HOME: gstackHome,
|
||||
HOME: tempHome,
|
||||
PATH: `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`,
|
||||
};
|
||||
|
||||
try {
|
||||
await runAgentSdkTest({
|
||||
@@ -185,6 +192,7 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
workingDirectory: gstackHome,
|
||||
maxTurns: 4,
|
||||
allowedTools: ['Read', 'Grep', 'Glob', 'Bash'],
|
||||
env: childEnv,
|
||||
...(binary ? { pathToClaudeCodeExecutable: binary } : {}),
|
||||
canUseTool: async (toolName, input) => {
|
||||
if (toolName === 'AskUserQuestion') {
|
||||
@@ -216,12 +224,9 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
});
|
||||
expect(privacyQuestions.length).toBe(0);
|
||||
} finally {
|
||||
if (origGstackHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = origGstackHome;
|
||||
if (origPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = origPath;
|
||||
fs.rmSync(gstackHome, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ Write DESIGN.md and CLAUDE.md (or update it) in the working directory.`,
|
||||
'Color': ['color', 'colour', 'palette', 'colors'],
|
||||
'Spacing': ['spacing', 'space', 'whitespace', 'gap'],
|
||||
'Layout': ['layout', 'grid', 'structure', 'composition'],
|
||||
'Motion': ['motion', 'animation', 'transition', 'movement'],
|
||||
'Motion': ['motion', 'animation', 'transition', 'movement', 'easing', 'duration', 'micro-interaction'],
|
||||
};
|
||||
const missingSections = Object.entries(sectionSynonyms).filter(
|
||||
([_, synonyms]) => !synonyms.some(s => designContent.toLowerCase().includes(s))
|
||||
@@ -152,7 +152,9 @@ Write DESIGN.md and CLAUDE.md (or update it) in the working directory.`,
|
||||
expect(['success', 'error_max_turns']).toContain(result.exitReason);
|
||||
expect(designExists).toBe(true);
|
||||
if (designExists) {
|
||||
expect(missingSections).toHaveLength(0);
|
||||
// join() so a failure names the offending section(s) — a bare
|
||||
// toHaveLength(0) failure never prints WHICH synonym set missed.
|
||||
expect(missingSections.join(', ')).toBe('');
|
||||
}
|
||||
if (claudeExists) {
|
||||
const claude = fs.readFileSync(claudePath, 'utf-8');
|
||||
|
||||
@@ -81,7 +81,10 @@ Write your complete review directly to ${planDir}/review-output.md
|
||||
Focus on reviewing the plan content: architecture, error handling, security, and performance.`,
|
||||
workingDirectory: planDir,
|
||||
maxTurns: 15,
|
||||
timeout: 360_000,
|
||||
// 540s: the evidence-before-claimed-limitations directive and the
|
||||
// design-doc discovery block (fork port wave 2) add real probing turns;
|
||||
// main cleared this at 243s, the enriched skill needs more headroom.
|
||||
timeout: 540_000,
|
||||
testName: 'plan-ceo-review',
|
||||
runId,
|
||||
model: 'claude-opus-4-7',
|
||||
@@ -100,7 +103,7 @@ Focus on reviewing the plan content: architecture, error handling, security, and
|
||||
const review = fs.readFileSync(reviewPath, 'utf-8');
|
||||
expect(review.length).toBeGreaterThan(200);
|
||||
}
|
||||
}, 420_000);
|
||||
}, 660_000);
|
||||
});
|
||||
|
||||
// --- Plan CEO Review (SELECTIVE EXPANSION) E2E ---
|
||||
@@ -168,7 +171,7 @@ Write your complete review directly to ${planDir}/review-output-selective.md
|
||||
Focus on reviewing the plan content: architecture, error handling, security, and performance.`,
|
||||
workingDirectory: planDir,
|
||||
maxTurns: 15,
|
||||
timeout: 360_000,
|
||||
timeout: 540_000,
|
||||
testName: 'plan-ceo-review-selective',
|
||||
runId,
|
||||
model: 'claude-opus-4-7',
|
||||
@@ -185,7 +188,7 @@ Focus on reviewing the plan content: architecture, error handling, security, and
|
||||
const review = fs.readFileSync(reviewPath, 'utf-8');
|
||||
expect(review.length).toBeGreaterThan(200);
|
||||
}
|
||||
}, 420_000);
|
||||
}, 660_000);
|
||||
});
|
||||
|
||||
// --- Plan CEO Review SCOPE EXPANSION energy (V1.1 mode-posture regression gate) ---
|
||||
|
||||
@@ -406,7 +406,9 @@ Do NOT fix any bugs. Do NOT use AskUserQuestion — just pick vitest.`,
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
// Module-level afterAll — finalize eval collector after all tests complete
|
||||
// Module-level afterAll — finalize eval collector after all tests complete.
|
||||
// Explicit 60s timeout: finalize does a JSON save + cross-run comparison and
|
||||
// has been observed at 6.26s, past bun's 5s default hook timeout.
|
||||
afterAll(async () => {
|
||||
await finalizeEvalCollector(evalCollector);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
@@ -130,15 +130,16 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
|
||||
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
|
||||
const binary = resolveClaudeBinary();
|
||||
|
||||
// Ambient env mutations. Restored in finally.
|
||||
const orig = {
|
||||
gstackHome: process.env.GSTACK_HOME,
|
||||
pathEnv: process.env.PATH,
|
||||
mcpToken: process.env.GBRAIN_MCP_TOKEN,
|
||||
// Per-test child env, passed via opts.env (merges last over the complete
|
||||
// hermetic env). Ambient process.env mutations DO NOT reach children:
|
||||
// hermetic-env scrubs GBRAIN_*/GSTACK_* by allowlist — this test's token
|
||||
// silently never arrived from the day hermetic env landed, and the child
|
||||
// correctly stopped at Step 4c with NEEDS_CONTEXT.
|
||||
const childEnv = {
|
||||
GSTACK_HOME: gstackHome,
|
||||
GBRAIN_MCP_TOKEN: SECRET_TOKEN,
|
||||
PATH: `${fakeBinDir}:${path.join(path.resolve(import.meta.dir, '..'), 'bin')}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`,
|
||||
};
|
||||
process.env.GSTACK_HOME = gstackHome;
|
||||
process.env.PATH = `${fakeBinDir}:${path.join(path.resolve(import.meta.dir, '..'), 'bin')}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
|
||||
process.env.GBRAIN_MCP_TOKEN = SECRET_TOKEN;
|
||||
|
||||
let modelTextOutput = '';
|
||||
|
||||
@@ -146,6 +147,7 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
|
||||
const skillPath = path.resolve(import.meta.dir, '..', 'setup-gbrain', 'SKILL.md');
|
||||
const result = await runAgentSdkTest({
|
||||
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
||||
env: childEnv,
|
||||
userPrompt:
|
||||
`Read the skill file at ${skillPath} and follow Path 4 (Remote MCP) only. ` +
|
||||
`Use this MCP URL: ${stubServer.url}. ` +
|
||||
@@ -195,6 +197,19 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
|
||||
|
||||
// Assertion 2: claude mcp add was called with --transport http.
|
||||
const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, 'utf-8') : '';
|
||||
if (!/mcp add.*--transport http/.test(calls)) {
|
||||
// Failure evidence: without this, the transcript dies with the test
|
||||
// and every triage pass starts blind (three did).
|
||||
const bashCmds = result.toolCalls
|
||||
.filter((t) => t.tool === 'Bash')
|
||||
.map((t) => String((t.input as { command?: string })?.command ?? '').slice(0, 200));
|
||||
console.error(
|
||||
`[setup-gbrain-remote] mcp-add never hit the fake shim.\n` +
|
||||
`exitReason=${result.exitReason} turns=${result.turnsUsed}\n` +
|
||||
`--- bash commands (${bashCmds.length}) ---\n${bashCmds.join('\n')}\n` +
|
||||
`--- final text (last 1500 chars) ---\n${result.output.slice(-1500)}`,
|
||||
);
|
||||
}
|
||||
expect(calls).toMatch(/mcp add.*--transport http/);
|
||||
|
||||
// Assertion 3: the secret token NEVER appears in the final CLAUDE.md.
|
||||
@@ -207,14 +222,16 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
|
||||
// Assertion 5: classifier — the model didn't write findings before
|
||||
// asking. The Path 4 prose has 5 STOP gates; if any of them got
|
||||
// skipped, that's the wrote_findings_before_asking pattern.
|
||||
const wroteBefore = /## GSTACK REVIEW REPORT|critical_gaps/i.test(modelTextOutput);
|
||||
// Scan the ASSISTANT's text only: modelTextOutput serializes every
|
||||
// event including the child's Read of the skill file, whose generated
|
||||
// footer contains the literal "GSTACK REVIEW REPORT" — a guaranteed
|
||||
// false positive on both trees.
|
||||
const wroteBefore = /## GSTACK REVIEW REPORT|critical_gaps/i.test(result.output);
|
||||
// Setup-gbrain doesn't have a review report contract, so this is
|
||||
// a structural shape check, not a hard failure mode.
|
||||
expect(wroteBefore).toBe(false);
|
||||
} finally {
|
||||
if (orig.gstackHome === undefined) delete process.env.GSTACK_HOME; else process.env.GSTACK_HOME = orig.gstackHome;
|
||||
if (orig.pathEnv === undefined) delete process.env.PATH; else process.env.PATH = orig.pathEnv;
|
||||
if (orig.mcpToken === undefined) delete process.env.GBRAIN_MCP_TOKEN; else process.env.GBRAIN_MCP_TOKEN = orig.mcpToken;
|
||||
// (no ambient process.env mutations to restore — env goes via opts.env)
|
||||
await stubServer.close();
|
||||
fs.rmSync(gstackHome, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
|
||||
@@ -158,7 +158,7 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
|
||||
const session = await launchClaudePty({
|
||||
permissionMode: 'plan',
|
||||
cwd: fixture.workTree,
|
||||
timeoutMs: 720_000,
|
||||
timeoutMs: 1_080_000,
|
||||
// Disable network-y pieces so the agent can't reach actual github.
|
||||
env: { GH_TOKEN: 'mock-not-real', NO_COLOR: '1' },
|
||||
seedSkills: true,
|
||||
@@ -172,7 +172,7 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
|
||||
const since = session.mark();
|
||||
session.send('/ship\r');
|
||||
|
||||
const budgetMs = 600_000;
|
||||
const budgetMs = 900_000;
|
||||
const start = Date.now();
|
||||
let lastPermSig = '';
|
||||
while (Date.now() - start < budgetMs) {
|
||||
@@ -234,6 +234,12 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Budget exhausted without a terminal signal: capture the tail NOW,
|
||||
// while the session is still alive. Only the break paths above set
|
||||
// evidence — without this, the timeout throw ships evidence: "".
|
||||
if (outcome === 'timeout') {
|
||||
evidence = session.visibleSince(since).slice(-3000);
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
@@ -273,6 +279,6 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
|
||||
try { fs.rmSync(path.dirname(fixture.workTree), { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
900_000, // 15 min wall clock
|
||||
1_200_000, // 20 min wall clock
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1216,9 +1216,11 @@ describe('Step 3.4 test coverage audit', () => {
|
||||
|
||||
describe('ship step numbering', () => {
|
||||
// Allowed sub-steps that are resolver-generated and intentionally nested:
|
||||
// 8.1 (Plan Verification), 8.2 (Scope Drift), 9.1 (Review Army), 9.2 (Findings Merge),
|
||||
// 9.3 (Cross-review dedup), 15.0 (WIP squash — continuous checkpoint), 15.1 (Bisectable commits).
|
||||
const ALLOWED_SUBSTEPS = new Set(['8.1', '8.2', '9.1', '9.2', '9.3', '15.0', '15.1']);
|
||||
// 0.9 (Apple target detection — MUST precede Step 1's branch gate, R2-pinned
|
||||
// by test/ship-apple-gate.test.ts), 8.1 (Plan Verification), 8.2 (Scope
|
||||
// Drift), 9.1 (Review Army), 9.2 (Findings Merge), 9.3 (Cross-review dedup),
|
||||
// 15.0 (WIP squash — continuous checkpoint), 15.1 (Bisectable commits).
|
||||
const ALLOWED_SUBSTEPS = new Set(['0.9', '8.1', '8.2', '9.1', '9.2', '9.3', '15.0', '15.1']);
|
||||
|
||||
test('ship/SKILL.md.tmpl contains no unexpected fractional step numbers', () => {
|
||||
const tmpl = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md.tmpl'), 'utf-8');
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Regression pin for #2018: /autoplan Phase 4's task aggregator emitted zero
|
||||
* tasks on every run, forever, for everyone.
|
||||
*
|
||||
* Root cause: the branch+commit filter in scripts/resolvers/tasks-section.ts
|
||||
* piped to the split commit array and THEN referenced `.commit` —
|
||||
*
|
||||
* select(.branch == $branch and ($commits | split("|") | index(.commit) != null))
|
||||
*
|
||||
* In jq, the pipe rebinds the context, so `.commit` was evaluated against the
|
||||
* split ARRAY ("Cannot index array with string \"commit\""), every input line
|
||||
* errored, stderr went to /dev/null, `|| true` swallowed the exit code, and
|
||||
* the aggregate was empty. A dead feature indistinguishable from "no tasks".
|
||||
*
|
||||
* These tests run the ACTUAL jq program extracted from the resolver source
|
||||
* against fixture JSONL, so they were RED against the broken filter and stay
|
||||
* red if anyone reintroduces a context-rebinding shape.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { execFileSync } from "child_process";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const SOURCE_PATH = join(import.meta.dir, "..", "scripts", "resolvers", "tasks-section.ts");
|
||||
|
||||
/**
|
||||
* Extract the emitted jq filter program from the resolver source. The
|
||||
* resolver builds bash inside a TS template literal, so `\\` in source is a
|
||||
* bash line-continuation `\` — strip it when unescaping. We match the
|
||||
* single-quoted jq program on the line that filters by $branch + $commits.
|
||||
*/
|
||||
function extractBranchCommitFilter(): string {
|
||||
const src = readFileSync(SOURCE_PATH, "utf-8");
|
||||
const m = src.match(/'([^']*select\(\.branch == \$branch[^']*)'/);
|
||||
if (!m) throw new Error("branch+commit jq filter not found in tasks-section.ts");
|
||||
return m[1].replace(/\\\\/g, "\\");
|
||||
}
|
||||
|
||||
function runJq(program: string, inputLines: string[], branch: string, commits: string): string[] {
|
||||
const out = execFileSync(
|
||||
"jq",
|
||||
["-c", "--arg", "branch", branch, "--arg", "commits", commits, program],
|
||||
{ input: inputLines.join("\n"), encoding: "utf-8" },
|
||||
);
|
||||
return out.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
const RECORD = (branch: string, commit: string) =>
|
||||
JSON.stringify({
|
||||
phase: "ceo-review",
|
||||
run_id: "20260814T000000Z-1",
|
||||
branch,
|
||||
commit,
|
||||
id: "T1",
|
||||
priority: "P1",
|
||||
component: "demo",
|
||||
files: ["a.ts"],
|
||||
effort_human: "~1h",
|
||||
effort_cc: "~5min",
|
||||
title: "demo task",
|
||||
source_finding: "demo finding",
|
||||
});
|
||||
|
||||
describe("tasks-section jq filter (#2018)", () => {
|
||||
it("matches a record whose branch and commit are in the window", () => {
|
||||
const program = extractBranchCommitFilter();
|
||||
const matched = runJq(
|
||||
program,
|
||||
[RECORD("feature/x", "abc123")],
|
||||
"feature/x",
|
||||
"abc123|def456",
|
||||
);
|
||||
// The broken filter returned [] here (every line errored) — the exact
|
||||
// #2018 symptom: reviews produced tasks, the aggregate table showed none.
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(JSON.parse(matched[0]).id).toBe("T1");
|
||||
});
|
||||
|
||||
it("filters out other branches and out-of-window commits", () => {
|
||||
const program = extractBranchCommitFilter();
|
||||
const matched = runJq(
|
||||
program,
|
||||
[
|
||||
RECORD("feature/x", "abc123"),
|
||||
RECORD("other-branch", "abc123"),
|
||||
RECORD("feature/x", "zzz999"),
|
||||
],
|
||||
"feature/x",
|
||||
"abc123|def456",
|
||||
);
|
||||
expect(matched).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("errors on no input line at all rather than fabricating output", () => {
|
||||
const program = extractBranchCommitFilter();
|
||||
expect(runJq(program, [], "feature/x", "abc123")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("source does not reference .commit after a context-rebinding pipe", () => {
|
||||
const src = readFileSync(SOURCE_PATH, "utf-8");
|
||||
// The bug shape: split("|") piped, then a bare `.commit` in the new array
|
||||
// context. Binding first (`.commit as $c`) is the required form.
|
||||
expect(src).not.toMatch(/split\("\|"\)\s*\|\s*index\(\.commit\)/);
|
||||
});
|
||||
});
|
||||
@@ -12,14 +12,20 @@
|
||||
* (bin/gstack-telemetry-log)
|
||||
*
|
||||
* gstack-telemetry-sync MUST strip every one of those fields before the remote
|
||||
* POST (bin/gstack-telemetry-sync). This test enforces that contract three ways:
|
||||
* POST (bin/gstack-telemetry-sync). The script has TWO strip paths — jq del()
|
||||
* is PRIMARY (structural, escape-proof), sed is the jq-less fallback — and
|
||||
* this test enforces the contract on both:
|
||||
*
|
||||
* 1. Coverage — every repo/branch field the producers emit is also stripped.
|
||||
* Catches "added a new repo field, forgot to strip it" (the rename-to-_repo
|
||||
* landmine, or any future producer drift).
|
||||
* 2. Behavior — run the ACTUAL sed strip expressions from the sync script over
|
||||
* a sample event line and assert no repo/branch field survives, while benign
|
||||
* fields do. Catches a broken/edited regex, not just a missing line.
|
||||
* 1. Coverage — every repo/branch field the producers emit is also stripped,
|
||||
* by every jq del() list AND by the sed expressions. Catches "added a new
|
||||
* repo field, forgot to strip it" (the rename-to-_repo landmine, or any
|
||||
* future producer drift) on whichever path a machine takes.
|
||||
* 2. Behavior — run the ACTUAL jq expression and the ACTUAL sed strip
|
||||
* expressions from the sync script over a sample event line and assert no
|
||||
* repo/branch field survives, while benign fields do. Catches a
|
||||
* broken/edited filter, not just a missing line. The jq leg also pins the
|
||||
* malformed-line contract: a line jq can't parse is dropped, never
|
||||
* forwarded unstripped.
|
||||
* 3. Floor — the three known fields are always in the stripped set, so deleting
|
||||
* a strip rule fails CI even if a producer also stops emitting it.
|
||||
*/
|
||||
@@ -45,6 +51,16 @@ function extractSedExprs(scriptText: string): string[] {
|
||||
return [...scriptText.matchAll(/-e\s+'(s\/[^']*)'/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
/** Pull every `jq -c 'del(...)'` filter out of the sync script, verbatim. */
|
||||
function extractJqDelFilters(scriptText: string): string[] {
|
||||
return [...scriptText.matchAll(/jq -c '(del\([^']*\))'/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
/** The JSON keys a jq del() filter removes, e.g. `del(._repo_slug, .repo)`. */
|
||||
function fieldsFromJqDel(filter: string): string[] {
|
||||
return [...filter.matchAll(/\.([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
/** The JSON key a strip expression targets, e.g. `,"repo":"[^"]*"` -> `repo`. */
|
||||
function fieldFromSedExpr(expr: string): string | null {
|
||||
const m = expr.match(/,"([A-Za-z_][A-Za-z0-9_]*)":/);
|
||||
@@ -73,6 +89,25 @@ describe('telemetry no-repo-identity-egress invariant', () => {
|
||||
const strippedFields = new Set(
|
||||
strippedRepoExprs.map(fieldFromSedExpr).filter((f): f is string => f !== null),
|
||||
);
|
||||
const jqFilters = extractJqDelFilters(syncText);
|
||||
|
||||
// Repo-identity fields the producers emit into the synced file — computed
|
||||
// once, asserted against BOTH strip paths (jq primary, sed fallback). Only
|
||||
// emission lines that target the synced file (skill-usage.jsonl) count: the
|
||||
// preamble appends directly; gstack-telemetry-log builds the synced event
|
||||
// with a `printf '{"v":1,...` line into $JSONL_FILE (= skill-usage.jsonl).
|
||||
const preambleSynced = fs
|
||||
.readFileSync(PREAMBLE, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.includes('skill-usage.jsonl'));
|
||||
const telLogSynced = fs
|
||||
.readFileSync(TEL_LOG, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.includes('"v":1') || l.includes('skill-usage'));
|
||||
const emitted = new Set<string>([
|
||||
...emittedRepoFields(preambleSynced),
|
||||
...emittedRepoFields(telLogSynced),
|
||||
]);
|
||||
|
||||
test('floor: the three known repo-identity fields are stripped', () => {
|
||||
for (const field of REPO_IDENTITY_FLOOR) {
|
||||
@@ -80,33 +115,34 @@ describe('telemetry no-repo-identity-egress invariant', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('coverage: every repo/branch field the producers emit into skill-usage.jsonl is stripped', () => {
|
||||
// Only emission lines that target the synced file (skill-usage.jsonl). The
|
||||
// preamble appends directly; gstack-telemetry-log builds the synced event
|
||||
// with a `printf '{"v":1,...` line into $JSONL_FILE (= skill-usage.jsonl).
|
||||
const preambleSynced = fs
|
||||
.readFileSync(PREAMBLE, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.includes('skill-usage.jsonl'));
|
||||
const telLogSynced = fs
|
||||
.readFileSync(TEL_LOG, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.includes('"v":1') || l.includes('skill-usage'));
|
||||
const emitted = new Set<string>([
|
||||
...emittedRepoFields(preambleSynced),
|
||||
...emittedRepoFields(telLogSynced),
|
||||
]);
|
||||
test('coverage: every repo/branch field the producers emit into skill-usage.jsonl is stripped (sed fallback path)', () => {
|
||||
// The preamble must emit "repo" — guards against the test silently passing
|
||||
// because a regex stopped matching the producer.
|
||||
expect(emitted.has('repo')).toBe(true);
|
||||
for (const field of emitted) {
|
||||
expect(
|
||||
strippedFields.has(field),
|
||||
`producer emits repo-identity field "${field}" but gstack-telemetry-sync does not strip it (would leak to remote)`,
|
||||
`producer emits repo-identity field "${field}" but gstack-telemetry-sync's sed fallback does not strip it (would leak to remote)`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('coverage: every jq del() list (the PRIMARY strip path) covers every emitted repo-identity field', () => {
|
||||
// Both tiers run a del() filter; each must strip full repo identity on its
|
||||
// own — a machine only ever takes one branch.
|
||||
expect(jqFilters.length).toBeGreaterThanOrEqual(2);
|
||||
expect(emitted.has('repo')).toBe(true); // producer-regex canary, as above
|
||||
for (const filter of jqFilters) {
|
||||
const delFields = new Set(fieldsFromJqDel(filter));
|
||||
for (const field of [...emitted, ...REPO_IDENTITY_FLOOR]) {
|
||||
expect(
|
||||
delFields.has(field),
|
||||
`jq filter "${filter}" does not del repo-identity field "${field}" (primary strip path would leak it to remote)`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('behavior: the real sed expressions remove repo identity, keep benign fields', () => {
|
||||
const sample =
|
||||
'{"v":1,"ts":"2026-06-02T00:00:00Z","skill":"design-shotgun",' +
|
||||
@@ -134,4 +170,51 @@ describe('telemetry no-repo-identity-egress invariant', () => {
|
||||
expect(cleaned).toContain('"sessions":3');
|
||||
expect(cleaned).toContain('"ts":"2026-06-02T00:00:00Z"');
|
||||
});
|
||||
|
||||
test('behavior: the real jq del() filters strip repo identity and drop malformed lines', () => {
|
||||
if (!Bun.which('jq')) return; // jq-less machine: the sed-fallback behavior test above is the live path
|
||||
|
||||
const sample =
|
||||
'{"v":1,"ts":"2026-06-02T00:00:00Z","skill":"design-shotgun",' +
|
||||
'"repo":"my-secret-repo","_repo_slug":"acme-my-secret-repo","_branch":"feature-x",' +
|
||||
'"sessions":3,"installation_id":"abc123"}';
|
||||
|
||||
// The identified-tier filter (no installation_id in its del list) and the
|
||||
// anonymous-tier filter (installation_id included) — run each verbatim.
|
||||
const identified = jqFilters.find((f) => !f.includes('installation_id'));
|
||||
const anonymous = jqFilters.find((f) => f.includes('installation_id'));
|
||||
expect(identified).toBeTruthy();
|
||||
expect(anonymous).toBeTruthy();
|
||||
|
||||
const runJq = (filter: string, input: string) => {
|
||||
const out = spawnSync(['jq', '-c', filter], { stdin: Buffer.from(input) });
|
||||
return { exitCode: out.exitCode, stdout: out.stdout.toString().trim() };
|
||||
};
|
||||
|
||||
const id = runJq(identified!, sample);
|
||||
expect(id.exitCode).toBe(0);
|
||||
// No repo/branch identity survives, value or key.
|
||||
expect(id.stdout).not.toContain('my-secret-repo');
|
||||
expect(id.stdout).not.toContain('feature-x');
|
||||
expect(id.stdout).not.toContain('"repo"');
|
||||
expect(id.stdout).not.toContain('_repo_slug');
|
||||
expect(id.stdout).not.toContain('_branch');
|
||||
// Benign fields are untouched; identified tier keeps installation_id.
|
||||
expect(id.stdout).toContain('"skill":"design-shotgun"');
|
||||
expect(id.stdout).toContain('"sessions":3');
|
||||
expect(id.stdout).toContain('"installation_id":"abc123"');
|
||||
|
||||
// Anonymous tier additionally drops installation_id.
|
||||
const anon = runJq(anonymous!, sample);
|
||||
expect(anon.exitCode).toBe(0);
|
||||
expect(anon.stdout).not.toContain('installation_id');
|
||||
expect(anon.stdout).not.toContain('my-secret-repo');
|
||||
|
||||
// Malformed line: jq fails and emits nothing — the sync script's
|
||||
// `|| CLEAN=""` + `[ -z "$CLEAN" ] && continue` drops it, so bytes the
|
||||
// strip never touched are never forwarded.
|
||||
const bad = runJq(identified!, '{"v":1,"repo":"my-secret-repo"');
|
||||
expect(bad.exitCode).not.toBe(0);
|
||||
expect(bad.stdout).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* gstack-verify-gate — Stop-hook enforcement tier.
|
||||
*
|
||||
* Pins the behaviours the gate exists for:
|
||||
* trust — a declared command NEVER runs until the user records it via
|
||||
* `gstack-verify-gate --trust` (per-repo command trust store).
|
||||
* block — trusted check fails, exit 2, turn cannot end.
|
||||
* allow — trusted check passes, exit 0.
|
||||
* fail open — nothing declared, exit 0. Absence never blocks.
|
||||
*
|
||||
* Plus the two safety branches: the Stop re-entry guard, and the static
|
||||
* opt-in contract (our adaptation): ./setup never registers the gate; the
|
||||
* settings-hook helper.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createHash } from 'crypto';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const GATE = path.join(ROOT, 'bin', 'gstack-verify-gate');
|
||||
|
||||
let dir: string;
|
||||
let gstackHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-verify-gate-'));
|
||||
gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-verify-gate-home-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.rmSync(gstackHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Declare a verification command in the project's CLAUDE.md (comment form). */
|
||||
function declareCheck(command: string): void {
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), `# Fixture\n\n<!-- gstack:verify: ${command} -->\n`);
|
||||
}
|
||||
|
||||
/** Write the check script the declaration points at. Touches `check-ran` when executed. */
|
||||
function check(exitCode: number, message: string): void {
|
||||
const script = path.join(dir, 'check.sh');
|
||||
fs.writeFileSync(script, `#!/bin/sh\ntouch check-ran\necho "${message}"\nexit ${exitCode}\n`);
|
||||
fs.chmodSync(script, 0o755);
|
||||
}
|
||||
|
||||
/** Did the declared check actually execute? */
|
||||
function checkRan(): boolean {
|
||||
return fs.existsSync(path.join(dir, 'check-ran'));
|
||||
}
|
||||
|
||||
interface RunOpts {
|
||||
stopHookActive?: boolean;
|
||||
cwd?: string;
|
||||
/** When false, CLAUDE_PROJECT_DIR is removed from the child env (walk-up mode). */
|
||||
projectDirEnv?: boolean;
|
||||
/** Hook-input session id, keys the re-entry attempt counter. */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
function gateEnv(projectDirEnv: boolean): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GSTACK_HOME: gstackHome };
|
||||
if (projectDirEnv) env.CLAUDE_PROJECT_DIR = dir;
|
||||
else delete env.CLAUDE_PROJECT_DIR;
|
||||
return env;
|
||||
}
|
||||
|
||||
function runGate(opts: RunOpts = {}): { code: number; stdout: string; stderr: string } {
|
||||
const input: Record<string, unknown> = { stop_hook_active: opts.stopHookActive ?? false };
|
||||
if (opts.sessionId) input.session_id = opts.sessionId;
|
||||
const r = spawnSync(GATE, {
|
||||
cwd: opts.cwd ?? dir,
|
||||
input: JSON.stringify(input),
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
env: gateEnv(opts.projectDirEnv ?? true),
|
||||
});
|
||||
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
}
|
||||
|
||||
/** Record the currently-declared command in the trust store. */
|
||||
function trust(opts: RunOpts = {}): { code: number; stdout: string; stderr: string } {
|
||||
const r = spawnSync(GATE, ['--trust'], {
|
||||
cwd: opts.cwd ?? dir,
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
env: gateEnv(opts.projectDirEnv ?? true),
|
||||
});
|
||||
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
}
|
||||
|
||||
describe('gstack-verify-gate trust store', () => {
|
||||
test('an untrusted declared command is NOT executed and does not block', () => {
|
||||
declareCheck('touch sentinel-ran');
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false);
|
||||
expect(r.stderr).toContain('--trust');
|
||||
expect(r.stderr).toContain('touch sentinel-ran');
|
||||
});
|
||||
|
||||
test('--trust records the command and prints a confirmation naming it', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(0, 'all good');
|
||||
|
||||
const t = trust();
|
||||
|
||||
expect(t.code).toBe(0);
|
||||
expect(t.stdout).toContain('./check.sh');
|
||||
});
|
||||
|
||||
test('after --trust the hook executes the command and block semantics work', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(1, 'totals mismatch');
|
||||
|
||||
expect(trust().code).toBe(0);
|
||||
const r = runGate();
|
||||
|
||||
expect(checkRan()).toBe(true);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.stderr).toContain('FAILED');
|
||||
expect(r.stderr).toContain('totals mismatch');
|
||||
});
|
||||
|
||||
test('a changed command is not executed until re-trusted', () => {
|
||||
declareCheck('true');
|
||||
expect(trust().code).toBe(0);
|
||||
|
||||
// Attacker (or anyone) edits the declaration after trust was granted.
|
||||
declareCheck('touch sentinel-ran');
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false);
|
||||
expect(r.stderr).toContain('--trust');
|
||||
|
||||
// Re-trusting the new command restores execution.
|
||||
expect(trust().code).toBe(0);
|
||||
const r2 = runGate();
|
||||
expect(r2.code).toBe(0);
|
||||
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(true);
|
||||
});
|
||||
|
||||
test('walk-up: hook run from a nested subdir keys trust on the CLAUDE.md root', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(0, 'all good');
|
||||
const nested = path.join(dir, 'a', 'b');
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
|
||||
// No CLAUDE_PROJECT_DIR: both trust and hook must walk up from $PWD.
|
||||
expect(trust({ cwd: nested, projectDirEnv: false }).code).toBe(0);
|
||||
const r = runGate({ cwd: nested, projectDirEnv: false });
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('passed');
|
||||
expect(checkRan()).toBe(true);
|
||||
});
|
||||
|
||||
test('non-comment declaration form (gstack:verify: cmd without <!-- -->) is honored', () => {
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\ngstack:verify: ./check.sh\n');
|
||||
check(0, 'all good');
|
||||
|
||||
expect(trust().stdout).toContain('./check.sh');
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('passed');
|
||||
expect(checkRan()).toBe(true);
|
||||
});
|
||||
|
||||
test('the trust store file is created 0600 under GSTACK_HOME', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(0, 'all good');
|
||||
expect(trust().code).toBe(0);
|
||||
|
||||
const store = path.join(gstackHome, 'verify-gate-trust');
|
||||
expect(fs.existsSync(store)).toBe(true);
|
||||
expect(fs.statSync(store).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
test('--trust fails cleanly when nothing is declared', () => {
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\nNothing declared here.\n');
|
||||
|
||||
const t = trust();
|
||||
|
||||
expect(t.code).not.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-verify-gate', () => {
|
||||
test('blocks the turn when the trusted check fails', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(1, 'totals mismatch');
|
||||
expect(trust().code).toBe(0);
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.stderr).toContain('FAILED');
|
||||
expect(r.stderr).toContain('totals mismatch');
|
||||
});
|
||||
|
||||
test('allows the turn when the trusted check passes', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(0, 'all good');
|
||||
expect(trust().code).toBe(0);
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('passed');
|
||||
});
|
||||
|
||||
test('fails open when CLAUDE.md declares no check', () => {
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\nNothing declared here.\n');
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain("declares no 'gstack:verify:' command");
|
||||
});
|
||||
|
||||
test('fails open when there is no CLAUDE.md at all', () => {
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('no CLAUDE.md');
|
||||
});
|
||||
|
||||
test('never invents a command: an empty declaration fails open', () => {
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '<!-- gstack:verify: -->\n');
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain("declares no 'gstack:verify:' command");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('gstack-verify-gate re-entry enforcement (no one-shot bypass)', () => {
|
||||
test('re-entry with a still-failing trusted check is blocked again', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(1, 'still failing');
|
||||
expect(trust().code).toBe(0);
|
||||
|
||||
const r = runGate({ stopHookActive: true, sessionId: 'sess-refail' });
|
||||
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.stderr).toContain('FAILED');
|
||||
expect(r.stderr).toContain('still failing');
|
||||
expect(checkRan()).toBe(true);
|
||||
});
|
||||
|
||||
test('re-entry after the check now passes is allowed', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(1, 'totals mismatch');
|
||||
expect(trust().code).toBe(0);
|
||||
expect(runGate({ sessionId: 'sess-fixed' }).code).toBe(2);
|
||||
|
||||
check(0, 'fixed now');
|
||||
const r = runGate({ stopHookActive: true, sessionId: 'sess-fixed' });
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('passed');
|
||||
});
|
||||
|
||||
test('attempt bound: repeated failing re-entries allow with a loud warning at the bound', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(1, 'never passing');
|
||||
expect(trust().code).toBe(0);
|
||||
const sid = 'sess-bound';
|
||||
|
||||
// First entry blocks and resets the episode counter.
|
||||
expect(runGate({ sessionId: sid }).code).toBe(2);
|
||||
|
||||
// Re-entries: bounded number of blocks, then allow-with-warning.
|
||||
const codes: number[] = [];
|
||||
let final: { code: number; stdout: string; stderr: string } | null = null;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const r = runGate({ stopHookActive: true, sessionId: sid });
|
||||
codes.push(r.code);
|
||||
if (r.code === 0) {
|
||||
final = r;
|
||||
break;
|
||||
}
|
||||
expect(r.code).toBe(2);
|
||||
}
|
||||
|
||||
expect(codes).toEqual([2, 2, 2, 0]);
|
||||
expect(final).not.toBeNull();
|
||||
expect(final!.stdout + final!.stderr).toContain('WARNING');
|
||||
|
||||
// A fresh first-entry run starts a new episode: blocked again, not allowed.
|
||||
expect(runGate({ sessionId: sid }).code).toBe(2);
|
||||
});
|
||||
|
||||
test('re-entry with an untrusted command keeps the exit-0-with-hint path', () => {
|
||||
declareCheck('touch sentinel-ran');
|
||||
|
||||
const r = runGate({ stopHookActive: true, sessionId: 'sess-untrusted' });
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false);
|
||||
expect(r.stderr).toContain('--trust');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-verify-gate trust-grant audit trail', () => {
|
||||
test('every --trust grant appends a JSON audit line (right sha256, 0600, verbatim cmd)', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(0, 'all good');
|
||||
|
||||
const t = trust();
|
||||
expect(t.code).toBe(0);
|
||||
// --trust prints the VERBATIM command being trusted.
|
||||
expect(t.stdout).toContain('./check.sh');
|
||||
|
||||
const log = path.join(gstackHome, 'security', 'verify-gate-trust-grants.jsonl');
|
||||
expect(fs.existsSync(log)).toBe(true);
|
||||
expect(fs.statSync(log).mode & 0o777).toBe(0o600);
|
||||
|
||||
const lines = fs.readFileSync(log, 'utf-8').trim().split('\n');
|
||||
expect(lines.length).toBe(1);
|
||||
const entry = JSON.parse(lines[0]);
|
||||
expect(entry.cmd).toBe('./check.sh');
|
||||
expect(entry.cmd_sha256).toBe(createHash('sha256').update('./check.sh').digest('hex'));
|
||||
expect(entry.root).toBe(fs.realpathSync(dir));
|
||||
expect(typeof entry.tty).toBe('boolean');
|
||||
expect(entry.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
|
||||
// A second grant appends, never truncates.
|
||||
declareCheck('true');
|
||||
expect(trust().code).toBe(0);
|
||||
const lines2 = fs.readFileSync(log, 'utf-8').trim().split('\n');
|
||||
expect(lines2.length).toBe(2);
|
||||
expect(JSON.parse(lines2[1]).cmd).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('opt-in contract (adapted from the fork: NOT registered by default)', () => {
|
||||
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
const gate = fs.readFileSync(GATE, 'utf-8');
|
||||
|
||||
test('./setup does NOT register the gate — a Stop hook running the verify command after every turn is opt-in', () => {
|
||||
expect(setup).not.toContain('verify-gate');
|
||||
});
|
||||
|
||||
test('the bin documents its own registration and removal commands', () => {
|
||||
expect(gate).toContain('remove-source --source verify-gate');
|
||||
expect(gate).toContain('gstack:verify:');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user