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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ bun run dist # electron-builder → dist/Campfire-<version>-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
Expand Down
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
62 changes: 62 additions & 0 deletions web/server/env-sanitizer.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {
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<string, string | undefined> = {
// 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);
});
});
48 changes: 48 additions & 0 deletions web/server/env-sanitizer.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = process.env,
): string[] {
const removed = Object.keys(env).filter(isClaudeSessionMarker).sort();
for (const key of removed) delete env[key];
return removed;
}
11 changes: 11 additions & 0 deletions web/server/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
38 changes: 38 additions & 0 deletions web/src/components/UpdateBanner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ beforeEach(() => {
dismissUpdate: mockDismissUpdate,
setUpdateOverlayActive: mockSetUpdateOverlayActive,
};
// Default: browser context, not the desktop shell.
delete (window as { campfireDesktop?: unknown }).campfireDesktop;
});

// ─── Visibility ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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(<UpdateBanner />);

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(<UpdateBanner />);

expect(screen.getByText("Download update")).toBeTruthy();
expect(screen.queryByText("Update & Restart")).toBeNull();
});
});

// ─── Interactions ──────────────────────────────────────────────────────────

describe("UpdateBanner interactions", () => {
Expand Down
22 changes: 21 additions & 1 deletion web/src/components/UpdateBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -40,7 +51,16 @@ export function UpdateBanner() {
<span className="text-cc-muted ml-1">(current: v{updateInfo.currentVersion})</span>
</span>

{updateInfo.isServiceMode ? (
{isDesktopApp() ? (
<a
href="https://github.com/stretchcloud/campfire/releases/latest"
target="_blank"
rel="noreferrer"
className="text-xs font-medium px-2.5 py-0.5 rounded-md bg-cc-primary hover:bg-cc-primary-hover text-white transition-colors"
>
Download update
</a>
) : updateInfo.isServiceMode ? (
<button
onClick={handleUpdate}
disabled={inProgress}
Expand Down
Loading