From 4f90f5b101cbc80753fc4f441b817b6456bfc936 Mon Sep 17 00:00:00 2001 From: Prasenjit Sarkar Date: Wed, 8 Jul 2026 10:29:20 +0100 Subject: [PATCH] fix(server): scrub inherited Claude session env; desktop-aware update banner A Campfire server started from inside a Claude Code session (agent-run terminal, or the desktop app opened from such a shell) inherited the host session's SDK env markers (CLAUDECODE, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH, ...). Spawned claude CLIs then expected host-managed OAuth, skipped their keychain credentials, and failed every API call with 401 authentication_failed. The server now strips these runtime markers from its own process.env at bootstrap, preserving deliberate configuration (CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_*). The update banner now shows a Download link to the GitHub releases page inside the desktop app instead of CLI update instructions, which only update the npm-installed server, not the app bundle. --- CHANGELOG.md | 7 +++ desktop/README.md | 10 ++++ desktop/package.json | 2 +- web/package.json | 2 +- web/server/env-sanitizer.test.ts | 62 ++++++++++++++++++++++++ web/server/env-sanitizer.ts | 48 ++++++++++++++++++ web/server/index.ts | 11 +++++ web/src/components/UpdateBanner.test.tsx | 38 +++++++++++++++ web/src/components/UpdateBanner.tsx | 22 ++++++++- 9 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 web/server/env-sanitizer.test.ts create mode 100644 web/server/env-sanitizer.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2482e61..cf45c66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ > Campfire began as a fork of [the-companion](https://github.com/The-Vibe-Company/companion) and diverged into a separate product. Pre-fork history (versions up to 0.42.0) lives in the upstream repository; Campfire's own releases start at 0.1.0. +## 0.4.2 (2026-07-08) + +### Fixes + +* **server:** scrub inherited Claude Code session env markers (`CLAUDECODE`, `CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH`, …) at bootstrap. A Campfire server started from inside a Claude Code session — an agent-run terminal, or the desktop app opened from such a shell — passed those markers to spawned `claude` CLIs, which then expected host-managed OAuth, skipped their own keychain credentials, and failed every call with `401 authentication_failed`. Deliberate configuration (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_*`) is preserved +* **desktop:** the update banner now shows a **Download update** link to the GitHub releases page inside the desktop app instead of CLI instructions (`the-campfire install` / Update & Restart), which only update the npm-installed server — not the app bundle. Desktop updates = download the new DMG and replace the app; `~/.campfire` state survives + ## 0.4.1 (2026-07-07) ### Fixes diff --git a/desktop/README.md b/desktop/README.md index c8743bf..651ad6c 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -42,6 +42,16 @@ bun run dist # electron-builder → dist/Campfire--arm6 (free-port scan, Campfire probe, readiness wait). They need no Electron install. +## Updating + +The bundled server's update checker compares its version against npm; when a +newer release exists, the in-app banner shows a **Download update** link to +the GitHub releases page. Updating = download the new DMG, drag Campfire into +Applications to replace the old copy. All state lives in `~/.campfire`, so +sessions, settings, and memory survive the swap. True in-app auto-update +(electron-updater / Squirrel.Mac) requires Apple code signing, so it is +deferred until a signing identity exists. + ## Signing ("cheap" distribution) Builds are **ad-hoc signed** (`scripts/after-pack.js`) — valid signature, no diff --git a/desktop/package.json b/desktop/package.json index b8ea28a..840eabc 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "campfire-desktop", - "version": "0.4.1", + "version": "0.4.2", "private": true, "description": "Campfire desktop app — native macOS shell around the Campfire server", "license": "MIT", diff --git a/web/package.json b/web/package.json index 975929b..20a579e 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "the-campfire", - "version": "0.4.1", + "version": "0.4.2", "type": "module", "description": "Campfire \u2014 collaborative web platform for AI coding agents. Run Claude Code, Codex, Goose, Aider, and more from one browser UI with real-time collaboration, permission voting, and automation.", "license": "MIT", diff --git a/web/server/env-sanitizer.test.ts b/web/server/env-sanitizer.test.ts new file mode 100644 index 0000000..aaea4d3 --- /dev/null +++ b/web/server/env-sanitizer.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { isClaudeSessionMarker, scrubClaudeSessionEnv } from "./env-sanitizer.js"; + +// Regression coverage for the desktop-app 401 bug: a Campfire server started +// from inside a Claude Code session inherited the host session's SDK env +// (CLAUDECODE, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH, …). +// A `claude` CLI spawned with those markers expects host-managed OAuth, +// skips its keychain credentials, and fails every API call with +// `401 authentication_failed`. The sanitizer strips the markers at server +// bootstrap while preserving deliberate user configuration. +describe("env-sanitizer", () => { + it("removes Claude session runtime markers observed in the real incident", () => { + const env: Record = { + CLAUDECODE: "1", + CLAUDE_CODE_SESSION_ID: "4d5e82e1", + CLAUDE_CODE_CHILD_SESSION: "1", + CLAUDE_CODE_ENTRYPOINT: "claude-code", + CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH: "1", + CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH: "1", + CLAUDE_CODE_OAUTH_SCOPES: "user:inference", + CLAUDE_CODE_EXECPATH: "/usr/local/bin/claude", + CLAUDE_AGENT_SDK_VERSION: "0.3.20", + CLAUDE_EFFORT: "xhigh", + CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES: "false", + }; + const removed = scrubClaudeSessionEnv(env); + + expect(Object.keys(env)).toEqual([]); + expect(removed).toContain("CLAUDECODE"); + expect(removed).toContain("CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH"); + expect(removed).toHaveLength(11); + }); + + it("preserves deliberate user configuration and unrelated variables", () => { + const env: Record = { + // Explicit auth override must survive — Campfire settings inject it too. + CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat-user-set", + // Feature flag Campfire sets for itself at bootstrap. + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1", + // Anthropic vars are user config (keys, proxies), never session markers. + ANTHROPIC_API_KEY: "sk-ant-user", + ANTHROPIC_BASE_URL: "https://api.anthropic.com", + HOME: "/Users/someone", + PATH: "/usr/bin", + }; + const before = { ...env }; + const removed = scrubClaudeSessionEnv(env); + + expect(removed).toEqual([]); + expect(env).toEqual(before); + }); + + it("classifies marker keys by prefix, not an exhaustive list", () => { + // Future SDK versions add new CLAUDE_CODE_* markers; the prefix rule must + // catch them without a code change. + expect(isClaudeSessionMarker("CLAUDE_CODE_SOME_FUTURE_FLAG")).toBe(true); + expect(isClaudeSessionMarker("CLAUDE_AGENT_FUTURE")).toBe(true); + expect(isClaudeSessionMarker("CLAUDE_CODE_OAUTH_TOKEN")).toBe(false); + expect(isClaudeSessionMarker("CLAUDEX_UNRELATED")).toBe(false); + expect(isClaudeSessionMarker("MY_CLAUDE_CODE_THING")).toBe(false); + }); +}); diff --git a/web/server/env-sanitizer.ts b/web/server/env-sanitizer.ts new file mode 100644 index 0000000..8708bd4 --- /dev/null +++ b/web/server/env-sanitizer.ts @@ -0,0 +1,48 @@ +/** + * Scrub Claude Code host-session runtime markers from an environment. + * + * When the Campfire server is started from inside a Claude Code session (an + * agent-run terminal, `open`-ing the desktop app from such a shell, a dev + * server launched by an agent), it inherits that session's SDK environment: + * CLAUDECODE=1, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH, + * CLAUDE_CODE_OAUTH_SCOPES, and friends. A `claude` CLI spawned with those + * markers believes a host process manages its OAuth tokens, skips its own + * keychain credentials, and every API call fails with + * `401 authentication_failed`. + * + * These markers describe a *session runtime*, never user configuration, so + * the server strips them from its own process.env at bootstrap — before any + * backend CLI, adapter, or terminal is spawned. Deliberate user configuration + * is preserved: + * - CLAUDE_CODE_OAUTH_TOKEN explicit auth override (also injected + * from Campfire settings) + * - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS feature flag Campfire itself sets + */ + +const ALLOWLIST = new Set([ + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", +]); + +/** True when the key is a Claude Code session runtime marker to remove. */ +export function isClaudeSessionMarker(key: string): boolean { + if (ALLOWLIST.has(key)) return false; + return ( + key === "CLAUDECODE" || + key === "CLAUDE_EFFORT" || + key.startsWith("CLAUDE_CODE_") || + key.startsWith("CLAUDE_AGENT_") + ); +} + +/** + * Remove Claude session markers in place. Returns the removed keys (sorted) + * so the caller can log what was scrubbed. + */ +export function scrubClaudeSessionEnv( + env: Record = process.env, +): string[] { + const removed = Object.keys(env).filter(isClaudeSessionMarker).sort(); + for (const key of removed) delete env[key]; + return removed; +} diff --git a/web/server/index.ts b/web/server/index.ts index ce54b9e..bce49b0 100644 --- a/web/server/index.ts +++ b/web/server/index.ts @@ -1,11 +1,22 @@ process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"; +// If this server was started from inside a Claude Code session (agent-run +// terminal, desktop app opened from such a shell), strip the inherited +// session markers — a `claude` CLI spawned with them expects host-managed +// OAuth and fails with 401 instead of using its own credentials. +import { scrubClaudeSessionEnv } from "./env-sanitizer.js"; +const scrubbedEnvKeys = scrubClaudeSessionEnv(); + // Enrich process PATH at startup so binary resolution and `which` calls can find // binaries installed via version managers (nvm, volta, fnm, etc.). // Critical when running as a launchd/systemd service with a restricted PATH. import { getEnrichedPath } from "./path-resolver.js"; process.env.PATH = getEnrichedPath(); +if (scrubbedEnvKeys.length > 0) { + console.log(`[server] Scrubbed inherited Claude session env: ${scrubbedEnvKeys.join(", ")}`); +} + import { dirname, resolve } from "node:path"; import { existsSync, readFileSync, statSync } from "node:fs"; import { fileURLToPath } from "node:url"; diff --git a/web/src/components/UpdateBanner.test.tsx b/web/src/components/UpdateBanner.test.tsx index b74e588..2f55663 100644 --- a/web/src/components/UpdateBanner.test.tsx +++ b/web/src/components/UpdateBanner.test.tsx @@ -43,6 +43,8 @@ beforeEach(() => { dismissUpdate: mockDismissUpdate, setUpdateOverlayActive: mockSetUpdateOverlayActive, }; + // Default: browser context, not the desktop shell. + delete (window as { campfireDesktop?: unknown }).campfireDesktop; }); // ─── Visibility ──────────────────────────────────────────────────────────── @@ -113,6 +115,42 @@ describe("UpdateBanner service mode", () => { }); }); +// ─── Desktop app ─────────────────────────────────────────────────────────── +// Inside the Electron shell (window.campfireDesktop set by the preload), CLI +// update paths don't apply: `the-campfire install` / Update & Restart update +// the npm-installed server, not the app bundle. The banner must instead link +// to the GitHub releases page where the new DMG lives. + +describe("UpdateBanner desktop app", () => { + beforeEach(() => { + (window as { campfireDesktop?: unknown }).campfireDesktop = { + isDesktop: true, + platform: "darwin", + version: "0.4.0", + }; + }); + + it("shows a download link to the releases page instead of CLI hints", () => { + storeState.updateInfo = makeUpdateInfo({ isServiceMode: false }); + render(); + + const link = screen.getByText("Download update") as HTMLAnchorElement; + expect(link.getAttribute("href")).toBe("https://github.com/stretchcloud/campfire/releases/latest"); + expect(link.getAttribute("target")).toBe("_blank"); + expect(screen.queryByText("the-campfire install")).toBeNull(); + }); + + it("prefers the download link even when the backing server is in service mode", () => { + // Update & Restart would update the npm service, not the app bundle the + // user is looking at — the DMG download is the only honest update path. + storeState.updateInfo = makeUpdateInfo({ isServiceMode: true }); + render(); + + expect(screen.getByText("Download update")).toBeTruthy(); + expect(screen.queryByText("Update & Restart")).toBeNull(); + }); +}); + // ─── Interactions ────────────────────────────────────────────────────────── describe("UpdateBanner interactions", () => { diff --git a/web/src/components/UpdateBanner.tsx b/web/src/components/UpdateBanner.tsx index 8e742f9..85534fb 100644 --- a/web/src/components/UpdateBanner.tsx +++ b/web/src/components/UpdateBanner.tsx @@ -2,6 +2,17 @@ import { useState } from "react"; import { useStore } from "../store.js"; import { api } from "../api.js"; +/** + * The desktop app's preload exposes window.campfireDesktop. In-app updates + * (`the-campfire install` / Update & Restart) only apply to the npm-installed + * server, so a desktop user updates by downloading the new DMG — the banner + * links to the releases page instead. True in-app auto-update on macOS would + * require Apple code signing. + */ +function isDesktopApp(): boolean { + return Boolean((window as { campfireDesktop?: { isDesktop?: boolean } }).campfireDesktop?.isDesktop); +} + export function UpdateBanner() { const updateInfo = useStore((s) => s.updateInfo); const dismissedVersion = useStore((s) => s.updateDismissedVersion); @@ -40,7 +51,16 @@ export function UpdateBanner() { (current: v{updateInfo.currentVersion}) - {updateInfo.isServiceMode ? ( + {isDesktopApp() ? ( + + Download update + + ) : updateInfo.isServiceMode ? (