mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 23:19:09 +02:00
fix: pre-landing review round — 8 auto-fixes + 8 accepted findings hardened
The ship review army (4 specialists + red-team + checklist, 29 findings)
produced 8 mechanical auto-fixes and 11 decisions; the accepted set:
- win32 slug parity completed: lib/bin-context.ts gains the remote-first
outermost walk + degraded-cache self-heal the bash side got this wave —
the two implementations now agree on the stray-marker live-bug shape,
pinned by shared fixtures (multi-specialist 9/10 finding).
- probe honors the plan's bounded-read decision: 256KB prefix, extraction
semantics mirrored from parseTranscriptJsonl so probe/prepare can never
diverge on the same file (>1MB transcript test).
- policy normalize parity: bash normalize() now matches canonicalizeRemote
on .git/-trailing and uppercase-.GIT shapes (7-shape corpus pinned two
ways) — a deny for those shapes could previously slip the transcript gate.
- session-update reclaim is TOCTOU-safe (atomic mv-aside on both branches).
- settings-hook: unparseable settings.json errors instead of being replaced
with {}; ensure-event keys on (event, source) so matcher changes update
in place — never zero or two registrations.
- dot-only slug guard at both parse sites (hostile 'url = ..' can't escape
projects/); enqueue tmp-file janitor (1h TTL, inside the drain lock);
brain-sync .migrating never clobbered; drop-queue/status count .migrating;
snapshot -o warning correct + surfaced in diff mode; version-bump test
order-dependence removed; uninstall clears the advance stamp.
Deferred with record: slug heal-probe cost sentinel (P3 TODO), FF_OK
conflation (noted, misdiagnosis-only).
270 pass / 0 fail across the 10 touched suites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9fecf0f16f
commit
b7d44c45b4
+25
-2
@@ -196,6 +196,11 @@ migrate_legacy_queue() {
|
||||
rm -f "$migrating" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
# If the leftover STILL holds records, the conversion failed (e.g. python3
|
||||
# unavailable). The mv below would overwrite it and destroy those records —
|
||||
# exactly the never-destroy invariant above. Defer this run's migration;
|
||||
# the next run retries both files.
|
||||
[ -s "$migrating" ] && return 0
|
||||
if [ -s "$QUEUE" ]; then
|
||||
mkdir -p "$QUEUE_DIR" 2>/dev/null || return 0
|
||||
mv -f "$QUEUE" "$migrating" 2>/dev/null || return 0
|
||||
@@ -482,6 +487,13 @@ subcmd_once() {
|
||||
# drain reads the spool (transition window for pre-spool writers).
|
||||
migrate_legacy_queue
|
||||
|
||||
# Janitor: reap orphaned enqueue temp files. A writer killed between its
|
||||
# tmp write and the atomic rename leaves `.tmp-*` behind forever — it never
|
||||
# becomes a record and nothing else touches it. One hour is far beyond any
|
||||
# live writer's write→rename window, so a fresh tmp (an in-flight enqueue)
|
||||
# is never touched. Runs inside the run lock, so it can't race the drain.
|
||||
find "$QUEUE_DIR" -maxdepth 1 -type f -name '.tmp-*' -mmin +60 -delete 2>/dev/null || true
|
||||
|
||||
local mode
|
||||
mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off)
|
||||
|
||||
@@ -691,11 +703,13 @@ subcmd_status() {
|
||||
echo '{"status":"unknown","message":"no status file yet"}'
|
||||
fi
|
||||
# Supplemental info (not in status file). Depth = spool record files plus
|
||||
# any not-yet-migrated legacy queue lines (transition window).
|
||||
# any not-yet-migrated legacy queue lines (transition window), including a
|
||||
# crash-leftover .migrating file — its records are still pending too.
|
||||
local queue_depth spool_depth legacy_depth
|
||||
spool_depth=$(ls "$QUEUE_DIR"/*.json 2>/dev/null | wc -l | tr -d ' ')
|
||||
legacy_depth=0
|
||||
[ -f "$QUEUE" ] && legacy_depth=$(wc -l < "$QUEUE" | tr -d ' ')
|
||||
[ -f "$QUEUE.migrating" ] && legacy_depth=$(( legacy_depth + $(wc -l < "$QUEUE.migrating" | tr -d ' ') ))
|
||||
queue_depth=$(( spool_depth + legacy_depth ))
|
||||
local last_push="never"
|
||||
[ -f "$LAST_PUSH_FILE" ] && last_push=$(cat "$LAST_PUSH_FILE" 2>/dev/null || echo never)
|
||||
@@ -727,7 +741,10 @@ subcmd_drop_queue() {
|
||||
echo "Refusing: --drop-queue discards pending syncs. Pass --yes to confirm." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Remove spool record files, then truncate any legacy queue remnant.
|
||||
# Remove spool record files, then truncate any legacy queue remnant —
|
||||
# including a crash-leftover .migrating file, whose records would otherwise
|
||||
# resurrect on the next drain via migrate_legacy_queue after the user
|
||||
# explicitly discarded the queue.
|
||||
local n=0 f
|
||||
for f in "$QUEUE_DIR"/*.json; do
|
||||
[ -e "$f" ] || continue
|
||||
@@ -739,6 +756,12 @@ subcmd_drop_queue() {
|
||||
n=$(( n + legacy_n ))
|
||||
: > "$QUEUE"
|
||||
fi
|
||||
if [ -f "$QUEUE.migrating" ]; then
|
||||
local mig_n
|
||||
mig_n=$(wc -l < "$QUEUE.migrating" | tr -d ' ')
|
||||
n=$(( n + mig_n ))
|
||||
rm -f "$QUEUE.migrating" 2>/dev/null || true
|
||||
fi
|
||||
if [ "$n" -eq 0 ]; then
|
||||
echo "queue already empty"
|
||||
exit 0
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
# .brain-queue.jsonl — legacy pending queue (pre-spool)
|
||||
# .brain-discover-cursor — discover-new cursor
|
||||
# .brain-last-push — timestamp marker
|
||||
# .brain-worktree-last-advance — daily worktree-advance stamp (#2516)
|
||||
# .brain-skip.txt — user-maintained skip list
|
||||
# .brain-sync.lock.d/ — lock dir (if present)
|
||||
# .brain-sync-status.json — health status
|
||||
@@ -125,6 +126,7 @@ rm -f "$GSTACK_HOME/.brain-queue.jsonl.migrating" 2>/dev/null || true
|
||||
rm -f "$GSTACK_HOME/.brain-discover-cursor" 2>/dev/null || true
|
||||
rm -f "$GSTACK_HOME/.brain-last-push" 2>/dev/null || true
|
||||
rm -f "$GSTACK_HOME/.brain-last-pull" 2>/dev/null || true
|
||||
rm -f "$GSTACK_HOME/.brain-worktree-last-advance" 2>/dev/null || true
|
||||
rm -f "$GSTACK_HOME/.brain-skip.txt" 2>/dev/null || true
|
||||
rm -f "$GSTACK_HOME/.brain-sync-status.json" 2>/dev/null || true
|
||||
rm -rf "$GSTACK_HOME/.brain-sync.lock.d" 2>/dev/null || true
|
||||
|
||||
@@ -92,14 +92,23 @@ normalize() {
|
||||
case "$head" in
|
||||
*:*) url=$(printf '%s' "$url" | sed 's|:|/|') ;;
|
||||
esac
|
||||
# Lowercase BEFORE the suffix strips so a `.GIT` suffix still strips —
|
||||
# parity with lib/gstack-memory-helpers' canonicalizeRemote, which strips
|
||||
# `.git` case-insensitively. GitHub and most hosts are case-insensitive on
|
||||
# paths anyway; collapsing avoids duplicate entries for "Foo/Bar" vs
|
||||
# "foo/bar". (Parity is pinned by test/gbrain-repo-policy-client.test.ts:
|
||||
# a key set through THIS normalize must be found via the canonicalized form
|
||||
# memory-ingest passes to `get --batch`.)
|
||||
url=$(printf '%s' "$url" | tr '[:upper:]' '[:lower:]')
|
||||
# Strip trailing slash(es) FIRST, so ".git/" still loses its suffix (same
|
||||
# order as canonicalizeRemote — slash-first, then .git, then re-strip).
|
||||
while [ "${url%/}" != "$url" ]; do url="${url%/}"; done
|
||||
# Strip trailing .git
|
||||
url="${url%.git}"
|
||||
# Strip trailing /
|
||||
url="${url%/}"
|
||||
# Lowercase the whole thing. GitHub and most hosts are case-insensitive on
|
||||
# paths anyway; collapsing avoids duplicate entries for "Foo/Bar" vs
|
||||
# "foo/bar".
|
||||
printf '%s\n' "$url" | tr '[:upper:]' '[:lower:]'
|
||||
# Re-strip trailing slash(es): a path remote ending in a `.git` directory
|
||||
# component ("/repo/.git") exposes a new trailing slash once .git is gone.
|
||||
while [ "${url%/}" != "$url" ]; do url="${url%/}"; done
|
||||
printf '%s\n' "$url"
|
||||
}
|
||||
|
||||
# ensure_file — create the policy file if missing, migrate if legacy.
|
||||
|
||||
+54
-22
@@ -1077,17 +1077,6 @@ export function readNewFailures(
|
||||
|
||||
// ── Main ingest passes ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lightweight attribution check: does a transcript have a resolvable git
|
||||
* remote for its cwd? Extracts the cwd from the first JSONL line that has
|
||||
* one (mirroring the logic in parseTranscriptJsonl) and calls
|
||||
* resolveGitRemote. Avoids the full parse (body rendering, message counting)
|
||||
* because probe only needs the yes/no answer.
|
||||
*
|
||||
* Non-transcript types (artifacts) always pass — the attribution filter in
|
||||
* preparePages only applies to transcripts (#2394).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The ONE attribution gate (#2394): a transcript is attributable iff its cwd
|
||||
* resolves to a git remote. Both probeMode (via transcriptIsAttributable) and
|
||||
@@ -1099,32 +1088,75 @@ function sessionIsAttributable(cwd: string | undefined | null): boolean {
|
||||
return resolveGitRemote(cwd) !== "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded prefix for the probe's cheap-parse (plan C7): transcripts run to
|
||||
* tens of MB, and the probe only needs the cwd, which both agent formats put
|
||||
* on the FIRST records. 256KB is orders of magnitude past any real header.
|
||||
*/
|
||||
const TRANSCRIPT_PROBE_MAX_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Lightweight attribution check: does a transcript have a resolvable git
|
||||
* remote for its cwd? Reads a BOUNDED prefix (first 256KB, never the whole
|
||||
* file — plan C7: the probe must stay a cheap parse on multi-MB transcripts),
|
||||
* extracts the cwd with EXACTLY parseTranscriptJsonl's rules, and calls
|
||||
* resolveGitRemote. Avoids the full parse (body rendering, message counting)
|
||||
* because probe only needs the yes/no answer.
|
||||
*
|
||||
* Extraction MIRRORS parseTranscriptJsonl (the single source of truth for
|
||||
* cwd semantics — keep the two in lockstep):
|
||||
* - the first PARSEABLE line decides the format (Codex: type=session_meta
|
||||
* or payload.id; else Claude Code);
|
||||
* - Codex cwd comes from that FIRST record ONLY (payload.cwd || cwd) —
|
||||
* a cwd appearing only on a later record is NOT used, exactly as
|
||||
* parseTranscriptJsonl ignores it, so probe and prepare can never
|
||||
* diverge on the same file;
|
||||
* - Claude Code cwd comes from the first record that carries one;
|
||||
* - unparseable lines are skipped (the truncated-tail case included).
|
||||
*
|
||||
* Non-transcript types (artifacts) always pass — the attribution filter in
|
||||
* preparePages only applies to transcripts (#2394).
|
||||
*/
|
||||
function transcriptIsAttributable(path: string): boolean {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, "utf-8");
|
||||
const fd = openSync(path, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(TRANSCRIPT_PROBE_MAX_BYTES);
|
||||
const n = readSync(fd, buf, 0, buf.length, 0);
|
||||
raw = buf.toString("utf-8", 0, n);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
|
||||
if (lines.length === 0) return false;
|
||||
|
||||
// Detect format: Codex first line has type=session_meta, Claude Code
|
||||
// has cwd on a user/assistant record.
|
||||
let cwd = "";
|
||||
let sawFirstParseable = false;
|
||||
for (const line of lines) {
|
||||
let rec: any;
|
||||
try {
|
||||
const rec = JSON.parse(line);
|
||||
if (rec?.type === "session_meta") {
|
||||
rec = JSON.parse(line);
|
||||
} catch {
|
||||
continue; // mirrors parseTranscriptJsonl: unparseable lines are skipped
|
||||
}
|
||||
if (!sawFirstParseable) {
|
||||
sawFirstParseable = true;
|
||||
// Format detection mirrors parseTranscriptJsonl's `first` record check.
|
||||
const isCodex = rec?.type === "session_meta" || rec?.payload?.id != null;
|
||||
if (isCodex) {
|
||||
// Codex: cwd comes from the session_meta FIRST record only.
|
||||
cwd = rec.payload?.cwd || rec.cwd || "";
|
||||
break;
|
||||
}
|
||||
if (rec?.cwd) {
|
||||
cwd = rec.cwd;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// Claude Code: first record with a cwd wins (the first record included).
|
||||
if (rec?.cwd) {
|
||||
cwd = rec.cwd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!cwd) return false;
|
||||
|
||||
@@ -73,16 +73,23 @@ fi
|
||||
[ -f "$_hb" ] || _hb="$LOCK_DIR"
|
||||
[ -n "$(find "$_hb" -maxdepth 0 -mmin +$LOCK_TTL_MINUTES 2>/dev/null)" ]
|
||||
}
|
||||
# Reclaim is TOCTOU-safe via atomic mv-aside: `rm -rf` then `mkdir` lets TWO
|
||||
# contenders both judge the lock stale, both remove it, and both win the
|
||||
# mkdir (one rm can land between the other's rm and mkdir). `mv` of the lock
|
||||
# dir is atomic — exactly one contender's mv succeeds; the loser's mv fails
|
||||
# (ENOENT) and it backs off. The winner reaps the moved-aside dir at leisure.
|
||||
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
||||
if lock_is_expired; then
|
||||
rm -rf "$LOCK_DIR" 2>/dev/null
|
||||
mv "$LOCK_DIR" "$LOCK_DIR.reap.$$" 2>/dev/null || { log_entry "SKIP lock_contested"; exit 0; }
|
||||
rm -rf "$LOCK_DIR.reap.$$" 2>/dev/null
|
||||
mkdir "$LOCK_DIR" 2>/dev/null || { log_entry "SKIP lock_contested"; exit 0; }
|
||||
log_entry "RECLAIMED lock_ttl_expired"
|
||||
elif [ -f "$LOCK_DIR/pid" ]; then
|
||||
LOCK_PID=$(cat "$LOCK_DIR/pid" 2>/dev/null || echo 0)
|
||||
if [ "$LOCK_PID" -gt 0 ] 2>/dev/null && ! kill -0 "$LOCK_PID" 2>/dev/null; then
|
||||
# Stale lock — remove and re-acquire
|
||||
rm -rf "$LOCK_DIR" 2>/dev/null
|
||||
# Stale lock — mv aside atomically (see reclaim note above), re-acquire
|
||||
mv "$LOCK_DIR" "$LOCK_DIR.reap.$$" 2>/dev/null || { log_entry "SKIP lock_contested"; exit 0; }
|
||||
rm -rf "$LOCK_DIR.reap.$$" 2>/dev/null
|
||||
mkdir "$LOCK_DIR" 2>/dev/null || { log_entry "SKIP lock_contested"; exit 0; }
|
||||
else
|
||||
# Live holder — or an empty/non-numeric pidfile inside the TTL
|
||||
|
||||
@@ -83,7 +83,17 @@ case "$ACTION" in
|
||||
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||
const hookCmd = process.env.GSTACK_HOOK_CMD;
|
||||
let settings = {};
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {}
|
||||
// An EXISTING file that does not parse must never be rewritten: the
|
||||
// old catch{} folded it to {} and the atomic write below replaced the
|
||||
// user permissions/env/other hooks with just ours. Refuse loudly.
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
|
||||
catch (e) {
|
||||
console.error("error: " + settingsPath + " exists but is not valid JSON (" +
|
||||
(e && e.message ? e.message : e) + "); refusing to rewrite it. Fix or move the file, then re-run.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
||||
const exists = settings.hooks.SessionStart.some(entry =>
|
||||
@@ -97,7 +107,7 @@ case "$ACTION" in
|
||||
const tmp = settingsPath + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
||||
fs.renameSync(tmp, settingsPath);
|
||||
' 2>/dev/null
|
||||
'
|
||||
;;
|
||||
|
||||
remove)
|
||||
@@ -178,18 +188,34 @@ case "$ACTION" in
|
||||
const ensure = process.env.GSTACK_ENSURE === "1";
|
||||
|
||||
let settings = {};
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {}
|
||||
// An EXISTING file that does not parse must never be rewritten: the
|
||||
// old catch{} folded it to {} and the atomic write below replaced the
|
||||
// user permissions/env/other hooks with just ours. Refuse loudly.
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
|
||||
catch (e) {
|
||||
console.error("error: " + settingsPath + " exists but is not valid JSON (" +
|
||||
(e && e.message ? e.message : e) + "); refusing to rewrite it. Fix or move the file, then re-run.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const before = JSON.stringify(settings, null, 2);
|
||||
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
if (!settings.hooks[event]) settings.hooks[event] = [];
|
||||
|
||||
// Identity key is (event, source): any existing entry carrying OUR
|
||||
// source tag for this event IS the entry to compare/update — a matcher
|
||||
// change must update it in place, never push a SECOND gstack entry
|
||||
// (the old key included the matcher, so a future matcher change would
|
||||
// have duplicated the registration). Untagged legacy entries are still
|
||||
// adopted when both matcher and command line up.
|
||||
const matchesEntry = (entry) => {
|
||||
if (entry._gstack_source === source) return true;
|
||||
const sameMatcher = (entry.matcher || "") === matcher;
|
||||
const sameCommand = entry.hooks && entry.hooks[0] && entry.hooks[0].command === cmd;
|
||||
const sameSource = entry._gstack_source === source;
|
||||
return sameMatcher && (sameSource || sameCommand);
|
||||
return sameMatcher && sameCommand;
|
||||
};
|
||||
|
||||
let existing = settings.hooks[event].find(matchesEntry);
|
||||
@@ -202,6 +228,10 @@ case "$ACTION" in
|
||||
if (existing) {
|
||||
existing.hooks = [hookEntry];
|
||||
existing._gstack_source = source;
|
||||
// Keep the matcher current too — under the (event, source) key the
|
||||
// matched entry may carry a stale matcher.
|
||||
if (matcher) existing.matcher = matcher;
|
||||
else delete existing.matcher;
|
||||
} else {
|
||||
const newEntry = { _gstack_source: source, hooks: [hookEntry] };
|
||||
if (matcher) newEntry.matcher = matcher;
|
||||
|
||||
@@ -224,6 +224,12 @@ if [[ -z "$SLUG" ]]; then
|
||||
if [[ -n "$REMOTE_URL" ]]; then
|
||||
RAW_SLUG=$(printf '%s' "${REMOTE_URL%.git}" | sed -E 's#.*[:/]([^/]+)/([^/]+)$#\1-\2#')
|
||||
SLUG=$(printf '%s' "$RAW_SLUG" | tr -cd 'a-zA-Z0-9._-')
|
||||
# Dot-only / degenerate guard: a hostile origin like `url = ..` (git
|
||||
# accepts it) passes sed unchanged and would become SLUG=".." — filing
|
||||
# state one level ABOVE ~/.gstack/projects/. Reject empty/"."/".."/
|
||||
# slash-bearing slugs and fall through to the basename fallback below.
|
||||
# (tr -cd already deletes "/", so */* is belt-and-braces.)
|
||||
case "$SLUG" in ""|.|..|*/*) SLUG="" ;; esac
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user