From 2be6c06ba83de82dcdd37a28d68d2787cfe8c63e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 15 Aug 2026 11:42:19 -0700 Subject: [PATCH] =?UTF-8?q?v1.65.0.0=20feat:=20fork=20port=20wave=202=20?= =?UTF-8?q?=E2=80=94=20feature=20fixes,=20session=20persistence,=20Apple?= =?UTF-8?q?=20releases,=20supply-chain=20CI=20(#2577)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(memory-ingest): pass --include-gitignored to gbrain import gstack-artifacts-init writes an ignore-everything .gitignore (a bare `*`, headed "Do not edit") at the root of ~/.gstack. The memory ingest stages pages into ~/.gstack/.staging-ingest--/, which is inside that repo, and gbrain's markdown collector honours .gitignore. The collector therefore matches every staged file against `*` and collects zero. The failure is silent. gbrain import exits 0 having imported nothing while the ingest prints `written: N` from the STAGED count rather than the imported count, so a run that indexes nothing looks identical to a healthy one and the memory corpus quietly stops growing. Reproduction, using git's own ignore machinery (no gbrain needed): git init . mkdir -p .staging-ingest-12345/learnings echo x > .staging-ingest-12345/learnings/page.md printf '*\n' > .gitignore git ls-files --others --exclude-standard # -> empty Passing --include-gitignored makes the import independent of whatever .gitignore sits above the staging directory. Adding a negation to the generated .gitignore is the alternative, but that file is gstack-owned and marked "Do not edit", so any regeneration silently reintroduces the bug. Adds a regression pin in the shape of memory-ingest-no-put_page.test.ts, plus a behavioural test for the collision itself. Both source pins fail against the unpatched file. * fix(memory-ingest): GIT_CEILING_DIRECTORIES defense-in-depth on the import child (#2144) Second layer under #2560's --include-gitignored: a realpath'd ceiling at the staging dir's parent pushes any git-enumerating collector off the git fast path (which sees zero files under ~/.gstack's ignore-everything root) onto its plain FS walk, even on gbrain builds whose flag semantics drift. Ceiling is realpath'd because git compares canonicalized directories during discovery — a staging dir reached through a symlink (macOS /var -> /private/var, symlinked $GSTACK_HOME) otherwise never matches. Behavioral tests prove discovery stops at the ceiling from the staging dir, including through a symlinked path, using git itself — no gbrain required. Mechanism ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian Co-Authored-By: Claude Fable 5 * fix(autoplan): Phase 4 task aggregator emitted zero tasks on every run (#2018) The branch+commit jq filter piped to the split commit array and then referenced .commit — jq rebinds context across a pipe, so .commit indexed the ARRAY with a string, every input line errored into 2>/dev/null, and || true swallowed the exit. The aggregate table has been empty for every user since the feature shipped. Bind .commit to a variable before the pipe. Functional pin extracts the ACTUAL emitted jq program from the resolver and runs it against fixture JSONL (verified RED against the broken filter), plus a source-shape guard against reintroducing a context-rebinding reference. Fix mechanism from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian Co-Authored-By: Claude Fable 5 * fix(codex): BSD mktemp templates broke /codex on every macOS install (#2091) macOS mktemp requires the X's to end the template; the five "codex-*-XXXXXX.txt" sites failed with "mkstemp failed ... File exists" before Codex ever ran (reproduced live on this machine). Same class fixed in claude/SKILL.md.tmpl's three sites. bin/gstack-paths now strips macOS's trailing slash from TMPDIR so TMP_ROOT-built paths stop carrying "//". Static tripwire scans every tracked .tmpl for characters after the X-run in a mktemp template (longer X-runs stay valid), plus a live portability check of the emitted shape. Co-authored-by: Sina Matian Co-Authored-By: Claude Fable 5 * fix(decision-log): --supersede silently discarded the replacement decision The supersede/redact branch appended the retirement event and exited before the JSON argument was ever read — a user recording a reversal WITH its replacement lost the replacement, and the payload finder's first-non-flag-arg predicate would have mistaken the target id for JSON anyway. Payloads are now identified by their leading brace, validated BEFORE any write, and appended FIRST (retirement second), so the only visible interleaving under a crash is both-active — recoverable, never lost. The replacement carries supersedes: provenance. Bare --supersede (the documented reversal-without-replacement) stays legal; --redact with a payload now refuses instead of dropping it. Ported from time-attack/gstack (GStack 2), tests included. Co-authored-by: Sina Matian Co-Authored-By: Claude Fable 5 * fix(brain-context): cold-start probe latency permanently disabled gbrain context gbrainAvailable() spawned gbrain --version under a 500ms budget; a cold CLI start on a loaded machine blew the timeout, misclassified gbrain as missing, and every skill session silently ran brainless — plus the per-query re-probe burned 3x the budget before any real work. Replaced with a memoized stat-based PATH scan (PATHEXT-aware on Windows) and made the query timeout overridable via GSTACK_BRAIN_TIMEOUT_MS for loaded CI environments. Also picks up the fork's manifest-filter coverage (#1687 shape) against the fake-gbrain harness — passes against our existing filter support. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian Co-Authored-By: Claude Fable 5 * fix(setup-gbrain): voyage-code-3 flags were silently dropped under zsh (#1798) zsh does not word-split an unquoted $VAR, so all three PGLite-init sites passed the entire flag string as ONE argv word — gbrain ignored it and silently fell back to its default embedding model, downgrading code retrieval for every zsh user (macOS default shell). Flags now ride the positional params (set -- ...; "$@"). Tests run the shape under BOTH bash and zsh against the fake-gbrain argv recorder (per-word argc log distinguishes one-blob from split), include a demonstration of the zsh collision on the old shape, and pin the template's three sites statically. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian Co-Authored-By: Claude Fable 5 * fix(model-benchmark): recognize macOS Keychain auth in the claude adapter (#1890) The default macOS Claude Code install stores OAuth under the Keychain generic-password service "Claude Code-credentials" and never writes ~/.claude/.credentials.json, so available()'s file-or-env sniff reported "No Claude auth found" while claude -p worked fine. On darwin the sniff now also probes the Keychain entry — metadata only (no -w, the secret is never read), 5s timeout, any security(1) failure degrades to not-found. Verified live on this machine (subscription install, no creds file, Keychain entry present). Co-Authored-By: Claude Fable 5 * fix(upgrade): v1.27 migration no longer auto-proceeds without a TTY or records a failed rename as done (#1383) Two silent-failure shapes in one script. Non-interactive runs (Claude Code Bash tool, CI) blanket-auto-proceeded into a REMOTE repo rename — now they skip-for-now by default and ask again next upgrade; unattended runs opt in with GSTACK_MIGRATE_ASSUME_YES=1. And a failed gh rename was journaled as done and the done-touchfile written, permanently stranding a half-renamed install — the failed step now stays PENDING with the manual command printed, finalize refuses the done-marker while any step is unjournaled, and the migration exits 1 with a re-run pointer while completed steps still skip on retry. Harness updated to opt in explicitly; new tests pin the default-skip and failure-stays-pending-then-retry-succeeds contracts (13/13). Co-Authored-By: Claude Fable 5 * fix(ship): REST fallback when gh pr edit hits the Projects-classic GraphQL deprecation (#1079) On repos where GitHub enforces the Projects-classic sunset, gh pr edit hard-errors on repository.pullRequest.projectCards and Step 19's PR body update dies. The template now names the error shape, says it is not an auth problem, and falls back to the REST endpoint (gh api pulls/N -X PATCH) with the SAME already-redaction-scanned temp file for body and title. Generated SKILL.md regen rides the cluster regen commit. Co-Authored-By: Claude Fable 5 * fix(ship): test-command detection was blind to Django and config-less-but-tested projects The Test Framework Bootstrap detected Python only via requirements.txt or pyproject.toml and treated missing config files as no-tests, so a green 'python manage.py test' Django app, a Go project with *_test.go beside the source, in-source Rust #[test] blocks, or a package.json with only a test script all got offered a SECOND test framework over a working one. Detection now enumerates definitive per-ecosystem markers (manage.py, tox.ini/setup.cfg, pom.xml/gradle, Makefile test targets, a tracked-file test census, in-source Rust tests) as EVIDENCE for the question it asks — never a command to run blind — preserving the read-CLAUDE.md-or-ask contract, with a marker→candidate-command table and ask-once persistence. The shared coverage-audit detection block gains the same markers. Test runs the resolver's emitted detection bash against Django / Go / Rust / Node fixtures in throwaway git repos. Ported from time-attack/gstack commit e3259078 (GStack 2). Co-authored-by: Sina Matian Co-Authored-By: Claude Fable 5 * chore: regenerate SKILL.md files for cluster A (autoplan jq, codex mktemp, setup-gbrain zsh, ship detection + REST fallback) Atomic regen of the 9 generated files whose templates/resolvers changed in the A-cluster commits. bun run gen:skill-docs, no hand edits. Co-Authored-By: Claude Fable 5 * test: refresh ship goldens + parity ratios for cluster A growth Codex/Factory hosts render single-file ship skills whose committed goldens must track template changes; refreshed from the regenerated renders. Parity size guards bumped with the growth itemized — ship (carve-guards) 1.08 -> 1.10 for the detection-evidence + REST-fallback growth measured at 1.090x, qa (parity-harness monolith invariant) 1.07 -> 1.12 for the shared coverage-audit markers measured at 1.111x. Kept tight so the next growth is a deliberate decision, not drift; the Apple adapter raises ship again with its own justification. Co-Authored-By: Claude Fable 5 * fix(gbrain-sync): enforce the per-repo policy at the code-import chokepoint (#2140 sync path) The deny/read-only tiers in ~/.gstack/gbrain-repo-policy.json were stored by gstack-gbrain-repo-policy but enforced only in /sync-gbrain skill prose — a direct or cron invocation of gstack-gbrain-sync ingested repo code regardless. Worse: the code stage's egress receipt has cited 'per-repo policy chokepoint (repoPolicyTier)' as its consent since v1.63 while no such function existed. repoPolicyTier() now gates the stage before the dry-run branch: deny → refused-policy-deny (exit 1, loud), read-only → clean skipped-policy-read-only (code ingest writes pages), unreadable store → fail-closed refused-policy-unreadable, no store → unchanged fail-open. Subprocess tests pin all four paths against real git repos and a permission-blocked store (verified RED against the ungated binary). The receipt's consent string is truthful from this commit. #2140's ingest-path source-isolation ask remains open — partial-progress comment at ship. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian Co-Authored-By: Claude Fable 5 * fix(ios-qa): /auth/sessions no longer hands raw bearer tokens to any local process The loopback sessions list echoed live tokens — a harvest-and-replay primitive for anything on the machine (same class as the /health token leak fixed in v1.63). The list now returns a device-salted 16-hex token_id plus metadata; the salt is shared with the attempts log so identifiers correlate. /auth/revoke keeps the list→revoke workflow alive by accepting token_id alongside the caller's own raw token and identity. saltedHash() is exported from audit.ts and writeAttempt now reuses it (was inlined). Integration tests pin raw-token absence, the id shape/metadata, and the token_id revoke round-trip (verified RED against the leaking handler). List fix ported from time-attack/gstack (GStack 2); token_id revoke is ours. Co-authored-by: Sina Matian Co-Authored-By: Claude Fable 5 * fix(ios-qa): boot token out of os_log entirely; IPv4 listener pinned to loopback at the socket The StateServer's bootstrap announce logged the live boot token with privacy: .public — and nothing consumed it: the daemon has read the token from the 0600 app-container file since the devicectl copy flow landed. The log line handed a credential to anything reading the unified log during the launch window. It now announces port/build only. The IPv4 listener bound the wildcard interface and relied on the per-connection peer check alone; IPv4 has no CoreDevice tunnel path, so it now binds 127.0.0.1 via requiredLocalEndpoint at the socket level. IPv6 keeps the wildcard bind for CoreDevice ULA peers by design. Static pins cover both the template and the fixture app copy. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian Co-Authored-By: Claude Fable 5 * fix(make-pdf): close the offline-gate bypass via raw-HTML fetch vectors With --allow-network off, the sanitizer stripped script/iframe/link but let Chromium fetch remote resources at print time through four raw-HTML vectors: `; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain("@import"); + expect(out).not.toContain("evil.example"); + expect(out).toContain("color: red"); + }); + + test("strips string-form @import (no url())", () => { + const out = sanitizeUntrustedHtml(``); + expect(out).not.toContain("@import"); + expect(out).not.toContain("evil.example"); + }); + + test("neutralizes remote url() inside `; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain("evil.example"); + expect(out).toContain("url(#)"); + expect(out).toContain("color: blue"); + }); + + test("neutralizes remote url() in inline style attributes", () => { + const input = `
x
`; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain("evil.example"); + expect(out).toContain("url(#)"); + expect(out).toContain("padding:4px"); + }); + + test("neutralizes protocol-relative url(//…) in style attributes", () => { + const out = sanitizeUntrustedHtml(`
x
`); + expect(out).not.toContain("evil.example"); + }); + + // ── Bypass regressions: unquoted style attributes ── + // HTML spec: an unquoted attribute value runs until whitespace or `>`, so + //
is live markup Chromium honors. + // The original neutralizer only rewrote quoted values. + + test("neutralizes remote url() in UNQUOTED style attributes", () => { + const out = sanitizeUntrustedHtml(`
x
`); + expect(out).not.toContain("evil.example"); + expect(out).toContain("url(#)"); + }); + + test("keeps local url() in unquoted style attributes functional", () => { + const out = sanitizeUntrustedHtml(`
x
`); + expect(out).toContain("url(local.png)"); + }); + + // ── Bypass regressions: CSS-escape obfuscation ── + // Chromium decodes CSS ident/string escapes before fetching, so \69 → i and + // \68 → h defeat literal-pattern matching. Untrusted styling has no + // legitimate need for escaped url schemes or at-rule names — fail closed. + + test("drops CSS-escaped @import (@\\69mport url(...)) in `); + expect(out).not.toContain("evil.example"); + expect(out).not.toMatch(/@\\/); // no escaped at-rule survives for Chromium to decode + }); + + test("drops CSS-escaped string-form @import (@\\69mport \"https://…\")", () => { + const out = sanitizeUntrustedHtml(``); + expect(out).not.toContain("evil.example"); + expect(out).not.toMatch(/@\\/); + }); + + test("neutralizes CSS-escaped scheme inside url() (\\68ttps://…)", () => { + const out = sanitizeUntrustedHtml(``); + expect(out).not.toContain("evil.example"); + }); + + test("neutralizes CSS-escaped function names (u\\72l(https://…))", () => { + const out = sanitizeUntrustedHtml(``); + expect(out).not.toContain("evil.example"); + }); + + test("neutralizes HTML-entity-encoded backslash escapes in style attributes", () => { + // Attribute values are entity-decoded by the HTML parser before the CSS + // parser runs, so \68ttps reaches Chromium as \68ttps → https. + const out = sanitizeUntrustedHtml(`
x
`); + expect(out).not.toContain("evil.example"); + }); + + // ── Bypass regressions: non-backslash entity obfuscation in style attrs ── + // The same attribute entity layer can hide ANY character of a fetch vector, + // not just backslashes: h → h, / → /. `; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain("evil.example"); + expect(out).toContain("url(#)"); + expect(out).toContain("color: blue"); + }); + + test("neutralizes remote image-set(...) in style attributes", () => { + const out = sanitizeUntrustedHtml(`
x
`); + expect(out).not.toContain("evil.example"); + }); + + test("neutralizes -webkit-image-set with a remote string argument", () => { + const out = sanitizeUntrustedHtml(``); + expect(out).not.toContain("evil.example"); + }); + + test("keeps local image-set(...) functional", () => { + const input = ``; + expect(sanitizeUntrustedHtml(input)).toContain(`image-set("local.png" 1x, "local@2x.png" 2x)`); + }); + + test("neutralizes remote