Skip to content
Closed
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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

## [1.40.1.0] - 2026-05-17

**gstack-paths stops writing state into another plugin's directory when CLAUDE_PLUGIN_DATA is set by a co-installed plugin.**

`gstack-paths` previously trusted `CLAUDE_PLUGIN_DATA` as a fallback for `GSTACK_STATE_ROOT` whenever `GSTACK_HOME` was unset. The intent was to support gstack installed as a Claude Code plugin. The problem: Claude Code sets `CLAUDE_PLUGIN_DATA` per-plugin at hook invocation time, and when another plugin (e.g. the Codex plugin) uses `CLAUDE_ENV_FILE` to persist its own `CLAUDE_PLUGIN_DATA` into the session environment, gstack would pick it up and write checkpoints, analytics, and learnings into that plugin's data directory. Since gstack ships as a skill in the most common install path, anyone with the Codex plugin installed hit this silently on every session.

### The numbers that matter

Source: `bun test test/gstack-paths.test.ts` (9 tests, all green).

| Scenario | Before | After |
|---|---|---|
| Codex plugin installed alongside gstack, no `GSTACK_HOME` set | `GSTACK_STATE_ROOT` = Codex plugin data dir | `GSTACK_STATE_ROOT` = `$HOME/.gstack` |
| gstack running as its own plugin (CLAUDE_PLUGIN_ROOT contains "gstack") | `GSTACK_STATE_ROOT` = gstack plugin data dir | `GSTACK_STATE_ROOT` = gstack plugin data dir (unchanged) |
| `GSTACK_HOME` explicitly set | `GSTACK_STATE_ROOT` = `GSTACK_HOME` | `GSTACK_STATE_ROOT` = `GSTACK_HOME` (unchanged) |

If you hit unexplained state cross-contamination or missing learnings/checkpoints while running gstack alongside Codex, this is the fix. No action needed — the next `./setup` run picks it up automatically.

### Itemized changes

#### Fixed

- `bin/gstack-paths`: guard the `CLAUDE_PLUGIN_DATA` branch so it only fires when `CLAUDE_PLUGIN_ROOT` confirms we are running as the gstack plugin (path contains "gstack"). Fixes cross-plugin state contamination when another plugin leaks its `CLAUDE_PLUGIN_DATA` into the session environment via `CLAUDE_ENV_FILE` (#1569).

#### For contributors

- `test/gstack-paths.test.ts`: replaced the `CLAUDE_PLUGIN_DATA wins over HOME when GSTACK_HOME unset` test with two targeted tests: one verifying `CLAUDE_PLUGIN_DATA` is ignored when `CLAUDE_PLUGIN_ROOT` is absent or points to a non-gstack plugin, one verifying it is respected when `CLAUDE_PLUGIN_ROOT` identifies gstack.

## [1.40.0.0] - 2026-05-16

## **gbrain sync stops biting users across the install path, slug algorithm, federation queue, and `.env.local` footgun.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.40.0.0
1.40.1.0
8 changes: 6 additions & 2 deletions bin/gstack-paths
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# CI / container env where HOME may be unset.
#
# Chains:
# GSTACK_STATE_ROOT: GSTACK_HOME -> CLAUDE_PLUGIN_DATA -> $HOME/.gstack -> .gstack
# GSTACK_STATE_ROOT: GSTACK_HOME -> CLAUDE_PLUGIN_DATA (only when CLAUDE_PLUGIN_ROOT=*gstack*) -> $HOME/.gstack -> .gstack
# PLAN_ROOT: GSTACK_PLAN_DIR -> CLAUDE_PLANS_DIR -> $HOME/.claude/plans -> .claude/plans
# TMP_ROOT: TMPDIR -> TMP -> .gstack/tmp (and mkdir -p, best-effort)
#
Expand All @@ -21,7 +21,11 @@ set -u
# State root: where gstack writes projects/, sessions/, analytics/.
if [ -n "${GSTACK_HOME:-}" ]; then
_state_root="$GSTACK_HOME"
elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then
elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ] && echo "${CLAUDE_PLUGIN_ROOT:-}" | grep -qi "gstack"; then
# Guard: only trust CLAUDE_PLUGIN_DATA when CLAUDE_PLUGIN_ROOT confirms we are
# running as the gstack plugin. Without this, a CLAUDE_PLUGIN_DATA from another
# plugin (e.g. codex) that leaked into the session env via CLAUDE_ENV_FILE would
# be picked up, writing all gstack state into the wrong directory.
_state_root="$CLAUDE_PLUGIN_DATA"
elif [ -n "${HOME:-}" ]; then
_state_root="$HOME/.gstack"
Expand Down
22 changes: 19 additions & 3 deletions test/gstack-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,28 @@ describe('gstack-paths', () => {
expect(got.GSTACK_STATE_ROOT).toBe('/tmp/explicit-state');
});

test('CLAUDE_PLUGIN_DATA wins over HOME when GSTACK_HOME unset', () => {
test('CLAUDE_PLUGIN_DATA ignored when CLAUDE_PLUGIN_ROOT is absent or non-gstack', () => {
// Without CLAUDE_PLUGIN_ROOT, falls through to HOME path.
const noRoot = run({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data', HOME: '/tmp/home' });
expect(noRoot.GSTACK_STATE_ROOT).toBe('/tmp/home/.gstack');

// With a CLAUDE_PLUGIN_ROOT that doesn't contain "gstack" (e.g. the codex plugin),
// still falls through to HOME path — this is the cross-plugin contamination scenario.
const wrongRoot = run({
CLAUDE_PLUGIN_DATA: '/tmp/codex-data',
CLAUDE_PLUGIN_ROOT: '/tmp/openai-codex',
HOME: '/tmp/home',
});
expect(wrongRoot.GSTACK_STATE_ROOT).toBe('/tmp/home/.gstack');
});

test('CLAUDE_PLUGIN_DATA respected when CLAUDE_PLUGIN_ROOT identifies gstack', () => {
const got = run({
CLAUDE_PLUGIN_DATA: '/tmp/plugin-data',
CLAUDE_PLUGIN_DATA: '/tmp/gstack-plugin-data',
CLAUDE_PLUGIN_ROOT: '/tmp/gstack-garrytan',
HOME: '/tmp/home',
});
expect(got.GSTACK_STATE_ROOT).toBe('/tmp/plugin-data');
expect(got.GSTACK_STATE_ROOT).toBe('/tmp/gstack-plugin-data');
});

test('HOME-derived state root when GSTACK_HOME and CLAUDE_PLUGIN_DATA unset', () => {
Expand Down
Loading