Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions setup-linux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -588,17 +588,53 @@ else
log_warning "Claude Code CLI, npx, or jq not found, skipping MCP server registration"
fi

# BUG-004: defense-in-depth wrapper around `claude plugin install`. The bash
# idempotence guard below (`grep -qF "$plugin"` against `claude plugin list`)
# yields a false negative for claude-mem@thedotmack -- it never appears in
# that listing (different marketplace, `@thedotmack` vs `@claude-plugins-official`).
# Every setup run installs claude-mem again, which triggers upstream
# anthropics/claude-code#59870: the CLI's deserialize-modify-serialize cycle
# drops fields outside its internal struct (organizationType,
# organizationRateLimitTier, projects map, onboarding flags), shrinking
# ~/.claude/.claude.json from ~75 KB to ~1.5 KB and forcing re-authentication.
# snapshot_claude_json copies the file to a tempfile BEFORE the install;
# restore_claude_json_if_truncated restores it AFTER, iff the snapshot was
# >= 10 KB and the new file is < 50% of the snapshot size. Complementary to
# SDD-021 session-start canary in claude-session-start.sh (same 10240-byte
# threshold, same upstream issue). See dotfiles#33 for the original incomplete
# trigger fix that motivated this layer.
snapshot_claude_json() {
local claude_json="$HOME/.claude/.claude.json"
[ -f "$claude_json" ] || return 0
local backup
backup=$(mktemp "${TMPDIR:-/tmp}/.claude.json.bug004.XXXXXX")
cp -f "$claude_json" "$backup"
printf '%s' "$backup"
}

restore_claude_json_if_truncated() {
local backup="$1"
[ -n "$backup" ] && [ -f "$backup" ] || return 0
local claude_json="$HOME/.claude/.claude.json"
if [ -f "$claude_json" ]; then
local snapshot_size new_size half
snapshot_size=$(stat -c %s "$backup" 2>/dev/null || echo 0)
new_size=$(stat -c %s "$claude_json" 2>/dev/null || echo 0)
half=$((snapshot_size / 2))
if [ "$snapshot_size" -ge 10240 ] && [ "$new_size" -lt "$half" ]; then
cp -f "$backup" "$claude_json"
log_warning ".claude.json shrunk from $snapshot_size to $new_size bytes after install (upstream #59870); restored from backup"
fi
fi
rm -f "$backup"
}

# Claude Code plugins (requires claude CLI).
# Idempotent: cache the installed-plugins list ONCE before the loop and skip
# entries already present. CRITICAL: every `claude plugin install` call
# writes to ~/.claude/.claude.json. The CLI's deserialize-modify-serialize
# cycle does NOT preserve fields outside its internal struct — subscription
# metadata (`organizationType: claude_max`, `organizationRateLimitTier`),
# the `projects` map, and onboarding flags get silently dropped. Re-running
# `plugin install` for plugins that are already installed is the trigger
# for `.claude.json` truncation (75k -> 1.5k), which makes Claude Code
# prompt for re-authentication in every project (subscription state lost).
# Same idempotence pattern as MCP registration (line 447).
# entries already present. The wrapper above (BUG-004) catches the
# false-negative case where the idempotence guard misses a plugin (e.g.
# claude-mem@thedotmack) and the resulting `claude plugin install` call
# truncates .claude.json. Same idempotence pattern as MCP registration (line 447).
if command -v claude >/dev/null 2>&1; then
log_info "Installing Claude Code plugins..."
installed_plugins=$(claude plugin list 2>/dev/null || true)
Expand All @@ -620,9 +656,13 @@ if command -v claude >/dev/null 2>&1; then
if printf '%s' "$installed_plugins" | grep -qF "$plugin"; then
plugins_skipped=$((plugins_skipped + 1))
else
# BUG-004: wrap the install with snapshot/restore so the upstream
# truncation bug (#59870) cannot drop subscription state.
_snap=$(snapshot_claude_json)
if claude plugin install "$plugin" >/dev/null 2>&1; then
plugins_added=$((plugins_added + 1))
fi
restore_claude_json_if_truncated "$_snap"
fi
done
log_success "Claude Code plugins ready ($plugins_added added, $plugins_skipped already present)"
Expand Down
59 changes: 54 additions & 5 deletions setup-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,49 @@ function Ensure-Directory {
}
}

# BUG-004: defense-in-depth wrapper around `claude plugin install`. Snapshots
# ~/.claude/.claude.json before the action; if the post-action size drops below
# 50% of the snapshot (and the snapshot was >= 10 KB), restores the snapshot.
# Defends against upstream anthropics/claude-code#59870: the CLI's deserialize-
# modify-serialize cycle drops fields outside its internal struct (organizationType,
# organizationRateLimitTier, projects map, onboarding flags), shrinking the file
# from ~75 KB to ~1.5 KB and forcing re-authentication in every project. The
# existing `installedPlugins -match` idempotence guard against `claude plugin list`
# yields a false negative for claude-mem@thedotmack (not present in that listing),
# so every setup run triggers a real install of claude-mem and hits #59870 --
# this wrapper is the second layer that catches the false-negative case.
# Complementary to SDD-021 session-start canary in claude-session-start.ps1
# (same 10240-byte threshold, same upstream issue, different detection moment).
# See dotfiles#33 for the original incomplete trigger fix.
function Backup-AndRestoreClaudeJson {
[CmdletBinding()]
param(
[Parameter(Mandatory)][scriptblock]$Action
)
$claudeJson = Join-Path $env:USERPROFILE '.claude\.claude.json'
$backup = $null
$snapshotSize = 0
if (Test-Path $claudeJson) {
$snapshotSize = (Get-Item $claudeJson).Length
$backup = [System.IO.Path]::GetTempFileName()
Copy-Item $claudeJson $backup -Force
}
try {
& $Action
} finally {
if ($backup -and (Test-Path $backup)) {
if ((Test-Path $claudeJson) -and $snapshotSize -ge 10240) {
$newSize = (Get-Item $claudeJson).Length
if ($newSize -lt ($snapshotSize / 2)) {
Copy-Item $backup $claudeJson -Force
Write-Warn ".claude.json shrunk from $snapshotSize to $newSize bytes after install (upstream #59870); restored from backup"
}
}
Remove-Item $backup -Force -ErrorAction SilentlyContinue
}
}
}

# Merge `ai/claude/settings.json` template into the deployed `~/.claude/settings.json`
# per the per-key policy in specs/SDD-002-settings-portability/proposal.md. Bootstrap
# when target missing. Preserves user customizations (Read paths,
Expand Down Expand Up @@ -355,12 +398,18 @@ if ($claudeCmd) {
$pluginsSkipped++
continue
}
try {
& claude plugin install $plugin 2>$null | Out-Null
$pluginsAdded++
} catch {
# Silently continue if a plugin fails
# BUG-004: wrap the install with the snapshot/restore guard so the upstream
# truncation bug (#59870) cannot drop subscription state. The existing
# `installedPlugins -match` idempotence above does NOT catch claude-mem
# (it does not appear in `claude plugin list` output).
Backup-AndRestoreClaudeJson -Action {
try {
& claude plugin install $plugin 2>$null | Out-Null
} catch {
# Silently continue if a plugin fails
}
}
$pluginsAdded++
}
Write-Success "Claude Code plugins ready ($pluginsAdded added, $pluginsSkipped already present)"
} else {
Expand Down
37 changes: 37 additions & 0 deletions specs/BUG-004-claude-mem-truncate-guard/features.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[
{
"id": "BUG-004-f1",
"behavior": "setup-windows.ps1 preserves .claude.json size across two consecutive runs on an authenticated machine (>=50 KB baseline)",
"verification": "bats tests/setup-windows.bats -f 'preserves .claude.json'",
"state": "pending",
"evidence": ""
},
{
"id": "BUG-004-f2",
"behavior": "setup-linux.sh preserves .claude.json size across two consecutive runs on an authenticated machine",
"verification": "bats tests/setup-linux.bats -f 'preserves .claude.json'",
"state": "pending",
"evidence": ""
},
{
"id": "BUG-004-f3",
"behavior": "Synthetic truncation triggers a single [WARNING] line citing upstream #59870 and restores the snapshot",
"verification": "bats tests/setup-windows.bats -f 'synthetic truncation'",
"state": "pending",
"evidence": ""
},
{
"id": "BUG-004-f4",
"behavior": "Fresh-machine path (no .claude.json) is a no-op (no temp file, no warning)",
"verification": "bats tests/setup-windows.bats -f 'fresh machine'",
"state": "pending",
"evidence": ""
},
{
"id": "BUG-004-f5",
"behavior": "Pre-call size below 10 KB does NOT trigger restoration even on large relative shrink",
"verification": "bats tests/setup-windows.bats -f '10 KB floor'",
"state": "pending",
"evidence": ""
}
]
71 changes: 71 additions & 0 deletions specs/BUG-004-claude-mem-truncate-guard/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
id: "BUG-004-claude-mem-truncate-guard"
type: spec
status: draft # draft | implementing | verifying | archived
created: "2026-05-19"
tags: [spec, proposal, bug, defense-in-depth, claude-cli, cross-os]
template_version: "1.0"
---

# BUG-004-claude-mem-truncate-guard

> **Naming**: file lives at `<repo>/specs/BUG-004-claude-mem-truncate-guard/proposal.md`.

## Why

<!-- from 11-tasks.md: BUG-004-claude-mem-truncate-guard *(P0, opens 2026-05-19)* — Trigger fix residual after SDD-021 monitor; `claude-mem@thedotmack` triggers `claude plugin install` truncation of `.claude.json` on every setup run. -->

Every run of `setup-windows.ps1` or `setup-linux.sh` silently truncates `~/.claude/.claude.json` from ~75 KB to ~1.5 KB, dropping `organizationType` / `organizationRateLimitTier` / onboarding flags, which forces re-authentication in every project on the next session. Root cause: the plugin-install idempotence guard checks the literal `claude-mem@thedotmack` against the output of `claude plugin list`, but that output only enumerates the `@claude-plugins-official` marketplace — `claude-mem` (from `@thedotmack` marketplace) **never matches**, so the guard yields a false negative on every run, triggering one real `claude plugin install claude-mem@thedotmack` call, which hits upstream `anthropics/claude-code#59870` (CLI's deserialize-modify-serialize cycle drops fields outside its internal struct). SDD-021 (✓ 2026-05-18) added a size monitor to `claude-session-start.{sh,ps1}` as a canary; the trigger fix originally claimed in dotfiles#33 is in practice incomplete — claude-mem is the residual trigger. Empirically reproduced 2026-05-19: setup output `Claude Code plugins ready (1 added, 11 already present)` → `.claude.json` 75k→3444 bytes → re-login prompt in every Claude Code session until restored from `~/.claude/backups/.claude.json.backup.1779138775569` (51980 bytes, 18 May 15:09).

## What

Both `setup-windows.ps1` and `setup-linux.sh` gain a defense-in-depth wrapper around the `claude plugin install` call inside the plugin-install loop. Before each install: snapshot `~/.claude/.claude.json` to a tempfile (Copy-Item / `cp`). After each install: read the new file size; if the **pre-install size was ≥ 10 KB** (sanity gate to avoid acting on fresh-machine tiny files) AND the **post-install size is < 50 % of the pre-install size**, restore the snapshot atomically. The wrapper always cleans the tempfile, success or failure. Restoration logs a `[WARNING]` line citing `anthropics/claude-code#59870`. The existing idempotence check on `claude plugin list` is preserved (still catches the common case for `@claude-plugins-official` entries); the wrapper is the second layer that catches false negatives like `claude-mem`.

Observable post-PR behaviour:

1. Running `setup-windows.ps1` or `setup-linux.sh` twice in a row on an authenticated machine leaves `.claude.json` size unchanged across runs (no re-login required).
2. If the upstream CLI bug fires anyway (e.g. another future plugin shows the same idempotence false-negative class), the wrapper restores `.claude.json` before the next call and emits a visible warning line in the setup log naming the file and the upstream issue.
3. The setup log gains exactly **one** new line per truncation event (no spam on healthy runs).

## Out of scope

Things this PR explicitly does NOT include. Forces a sharp boundary and prevents scope creep.

- Fixing the underlying upstream bug `anthropics/claude-code#59870` — that lives in `@anthropic-ai/claude-code` CLI source, not in this repo. Defence in depth is the only handle we have.
- Removing `claude-mem@thedotmack` from the install array — it remains a legitimate dependency (active MCP for conversation memory per `CLAUDE.md`). The goal is to make its install idempotent under the upstream bug, not to drop it.
- Switching the idempotence regex to also catch claude-mem — the literal `claude-mem@thedotmack` genuinely does not appear in `claude plugin list` output, so no regex fixes the root false negative. The wrapper is the correct layer.
- Replacing SDD-021's session-start size monitor — that canary stays as a complementary alarm (catches truncations from sources other than this setup script). BUG-004 is the trigger fix; SDD-021 remains the detector.
- BUG-005 (PowerShell 5.1 `-AsHashtable` re-exec) — separate atomic PR with its own spec folder.
- Re-running `claude` commands in CI to detect the issue automatically — out of scope; the bats tests assert on the helper's shape and integration, not on a live `claude` invocation.

## Risks / open questions

Failure modes, dependencies, and unknowns to clarify before implementation. If any item here is unresolved, do not move to `tasks.md` yet.

- **Threshold heuristic** (10 KB floor + 50 % shrink): chosen to match SDD-021's existing canary threshold (10 KB) plus a relative drop to tolerate small absolute changes (e.g. legitimate addition of a single plugin entry growing the file by ~200 bytes). False positive: a legitimate operation that genuinely shrinks `.claude.json` to <50 % of its size. None known. False negative: a future bug variant that shrinks by exactly 49 % — defended by SDD-021's absolute-size canary at session start.
- **Subscription state vs. plugin state coupling**: restoring the snapshot reverts any **legitimate** writes the install would have made to `.claude.json` (e.g. registering the new plugin in the manifest section). Mitigation: the subsequent re-run of setup will retry the install; the upstream CLI is the one losing state on each call, so dropping that single write is preferable to losing subscription state. Documented in inline comments.
- **macOS / BSD `stat` flag differences**: `setup-linux.sh` already targets Linux only (Docker integration test is Ubuntu 24.04 per `tests/Dockerfile.integration`). Cross-platform stat (`-c %s` vs `-f %z`) is out of scope; the helper uses GNU `stat -c %s`. If macOS support comes later (separate spec), the helper takes a one-line conditional.
- **PowerShell scope of counter increment**: the original loop increments `$pluginsAdded++` after a successful install. The new wrapper must preserve this without forcing `$script:` scope quirks. Resolved by returning a boolean from the helper and incrementing in the loop body.
- **Concurrency**: not a real risk — setup runs single-threaded; no other process is expected to write to `.claude.json` between snapshot and restore. Documented for completeness.

## Acceptance criteria

Observable outcomes. Each must be testable.

- [ ] After running `setup-windows.ps1` twice in a row on a Windows machine with `pwsh` and `~/.claude/.claude.json` > 50 KB, the file size is unchanged across the two runs (within ±1 byte) and no re-authentication prompt appears in a subsequent Claude Code session.
- [ ] Same as above on Linux with `setup-linux.sh`.
- [ ] When a synthetic truncation is simulated (replace `claude plugin install` with a stub that overwrites `.claude.json` with `{}`), the helper restores the pre-call snapshot and emits exactly one `[WARNING] .claude.json shrunk from <X> to <Y> bytes after install (upstream #59870); restored from backup` line to stdout/stderr.
- [ ] On a fresh machine where `~/.claude/.claude.json` does not exist before the install loop, the helper is a no-op (no temp file lingers, no warning fires) — verified by bats stubbed-env test.
- [ ] On a fresh machine where `~/.claude/.claude.json` exists but is < 10 KB (e.g. 2 KB), a shrink to 1 KB does NOT trigger restoration (the 10 KB floor gates it) — verified by bats.
- [ ] PSScriptAnalyzer (Error+Warning) clean on `setup-windows.ps1`; `bash -n setup-linux.sh` clean; `bats tests/setup-windows.bats tests/verify-setup.bats` green.
- [ ] CI 5/5 green on the PR (GitGuardian, integration, lint, lint-powershell, test).

## References

- Vault: `10_projects/dotfiles/11-tasks.md` (BUG-004 backlog entry)
- Upstream issue: [`anthropics/claude-code#59870`](https://github.com/anthropics/claude-code/issues/59870) (CLI deserialize-modify-serialize cycle drops fields)
- Related: dotfiles#33 (original trigger fix, now identified as incomplete for claude-mem)
- Related: SDD-021 (vault `11-tasks.md`, completed 2026-05-18) — session-start size monitor (canary, complementary)
- Related ADR: `30-architecture/adr-007-mcp-persistence-and-auto-memory.md` (rationale for `claude-mem` being deployed via setup)
- Sibling spec: `specs/BUG-005-setup-ps7-reexec/` (PR after this one)
- Pattern: `00_meta/patterns/pattern-setup-script-idempotence.md` (existing pattern; this spec extends it with the snapshot/restore layer)
Loading
Loading